Workers and Cron
Running a Messenger consumer as its own Deployment, scheduled console commands as a CronJob, and shutting both down cleanly on SIGTERM.
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128MTwo 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
// 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.
# 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: 256MiA 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.
# 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: prodThe 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.
// 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