Workers and Cron

Running a Messenger consumer as its own Deployment, scheduled console commands as a CronJob, and shutting both down cleanly on SIGTERM.

Syntaxphp bin/console messenger:consume async --time-limit=3600 --memory-limit=128M

Two kinds of process in a Symfony application never answer an HTTP request: the Messenger consumer, which runs continuously and handles queued messages, and scheduled commands, which run and exit. Neither belongs in the web Deployment, because each needs different scaling, different limits and a different idea of healthy.

The consumer is a Deployment with no Service

Run messenger:consume as the container command. The Deployment gives you replicas, which here means how many messages are handled in parallel, and automatic replacement when a worker dies. No Service is needed because nothing connects to a worker. Add --time-limit and --memory-limit so the process exits on its own schedule; a long-running PHP process accumulates memory, and a worker that exits cleanly is restarted by Kubernetes within seconds.

Graceful shutdown

To stop a pod, Kubernetes sends SIGTERM, waits terminationGracePeriodSeconds (30 by default), then sends SIGKILL. messenger:consume handles SIGTERM and SIGINT by default: it finishes the message in hand and exits. Set the grace period above your slowest handler, or the kill arrives mid-handler and the message is redelivered.

Scheduled commands are a CronJob

A CronJob creates a Job on a schedule, and the Job runs a pod to completion. Use concurrencyPolicy: Forbid so a slow run is not overlapped by the next one.

What trips people up

A CronJob schedule is interpreted in UTC unless spec.timeZone is set, which is stable from Kubernetes 1.27.

Example

Example · php
// src/Command/PurgeExpiredCartsCommand.php
declare(strict_types=1);

namespace App\Command;

use App\Cart\CartPurger;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
    name: 'app:carts:purge-expired',
    description: 'Deletes carts abandoned more than 30 days ago.',
)]
final class PurgeExpiredCartsCommand extends Command
{
    public function __construct(private readonly CartPurger $purger)
    {
        // Required when extending Command: the parent sets up the definition.
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        $deleted = $this->purger->purgeOlderThanDays(30);
        $io->success(sprintf('%d carts deleted.', $deleted));

        // The exit code is what the Job reports: 0 succeeds, anything else fails.
        return Command::SUCCESS;
    }
}

// php bin/console app:carts:purge-expired --no-interaction
//
//  [OK] 412 carts deleted.
//

When to use it

  • Scale message handling independently of web traffic by changing the worker replica count.
  • Let a deploy replace workers without losing the message a worker is in the middle of.
  • Run a nightly cleanup command exactly once, whatever the number of web replicas.
  • Recycle workers hourly so a slow memory leak never reaches the container limit.

More examples

The worker Deployment

The same image as the web pods, started differently: one image per release keeps worker and web code in step, which matters because both read the same database. There are no HTTP probes, because nothing listens on a port. --memory-limit should sit below the container memory limit so the worker exits of its own accord instead of being killed by the kernel for being out of memory. -v makes each handled message visible in the pod log.

Example · yaml
# k8s/worker-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: worker
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: worker
  template:
    metadata:
      labels:
        app.kubernetes.io/name: worker
    spec:
      # Must exceed the slowest handler, or SIGKILL lands mid-message.
      terminationGracePeriodSeconds: 120
      containers:
        - name: php
          image: registry.example.com/shop/web:2026-10-03.1
          command: ["php", "bin/console", "messenger:consume", "async"]
          args:
            - "--time-limit=3600"
            - "--memory-limit=128M"
            - "-v"
          env:
            - name: APP_ENV
              value: prod
          resources:
            requests:
              cpu: 100m
              memory: 160Mi
            limits:
              memory: 256Mi

A CronJob for the scheduled command

Reach for a CronJob instead of a crontab in the image, because a crontab would run once per replica. Forbid skips a run while the previous one is still going, which protects a command that is not safe to run twice at once. --no-interaction matters: a command that asks a question with no terminal attached will hang or fail. The history limits keep a few finished pods so their logs can be read, and restartPolicy: Never with backoffLimit: 2 gives two retries before the Job is marked failed.

Example · yaml
# k8s/cronjob-purge-carts.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: purge-carts
spec:
  schedule: "15 2 * * *"
  timeZone: "Europe/London"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: php
              image: registry.example.com/shop/web:2026-10-03.1
              command:
                - php
                - bin/console
                - app:carts:purge-expired
                - --no-interaction
              env:
                - name: APP_ENV
                  value: prod

The same command written as an invokable class

This is the shorthand for the primary example and the two are the same command: same name, same exit code, same CronJob entry. The class does not extend Command, so there is no parent constructor to call, and the OutputInterface argument is injected into __invoke. Symfony 8.1 supports both forms, so you will meet each of them in real projects; recognising that the invokable class is still a console command is the point. Where the longhand is needed is the lifecycle hooks, initialize() and interact(), which exist only on classes that extend Command.

Example · php
// src/Command/PurgeExpiredCartsCommand.php
declare(strict_types=1);

namespace App\Command;

use App\Cart\CartPurger;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
    name: 'app:carts:purge-expired',
    description: 'Deletes carts abandoned more than 30 days ago.',
)]
final class PurgeExpiredCartsCommand
{
    public function __construct(private readonly CartPurger $purger)
    {
    }

    public function __invoke(OutputInterface $output): int
    {
        $deleted = $this->purger->purgeOlderThanDays(30);
        $output->writeln(sprintf('%d carts deleted.', $deleted));

        return Command::SUCCESS;
    }
}

Discussion

  • Be the first to comment on this lesson.