Scheduling and Workflows
Recurring work with the Scheduler component, one-off delays with a stamp, and a process manager that keeps the worker alive.
RecurringMessage::cron(string $expression, object $message, \DateTimeZone|string|null $timezone = null): RecurringMessageSome work is not triggered by a request: a nightly digest, a ten-minute price sync, a weekly cleanup. The Scheduler component produces messages on a timetable and feeds them to the bus and handlers you already have. It was added in Symfony 6.3; the task attributes below arrived in 6.4.
A schedule provider
Write a class implementing ScheduleProviderInterface and return a Schedule from getSchedule(). Each entry is a RecurringMessage: RecurringMessage::cron('@daily', new SendDigest()) for cron expressions, RecurringMessage::every('10 minutes', new SyncPrices()) for intervals. Cron expressions need composer require dragonmantank/cron-expression. The #[AsSchedule] attribute names the schedule, and that name becomes a transport: php bin/console messenger:consume scheduler_default.
Shorthand and longhand
#[AsCronTask('@daily')] on an invokable class is shorthand: it registers the same recurring message on the default schedule without you writing a provider. #[AsPeriodicTask('1 hour')] is the interval equivalent. Write the provider yourself when the timetable needs real code, or when you want everything recurring in one readable file.
Delays, and staying alive
A one-off delay is a stamp, not a schedule: new DelayStamp(600_000) holds a message for ten minutes. A worker, meanwhile, exits on purpose, on its time limit or on messenger:stop-workers during a deploy, so a process manager such as Supervisor must start it again with autorestart=true.
Example
// src/Scheduler/MainSchedule.php
declare(strict_types=1);
namespace App\Scheduler;
use App\Message\PruneAuditLog;
use App\Message\SendDailyDigest;
use App\Message\SyncPrices;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
// The name becomes the transport name: scheduler_default.
#[AsSchedule('default')]
final class MainSchedule implements ScheduleProviderInterface
{
// stateful() takes a Symfony Contracts CacheInterface, not a PSR-6 pool.
public function __construct(private readonly CacheInterface $cache)
{
}
public function getSchedule(): Schedule
{
return (new Schedule())
->add(
// Cron expression, in an explicit timezone rather than the
// server's. '@daily' and '0 0 * * *' mean the same thing.
RecurringMessage::cron(
'7 3 * * *',
new SendDailyDigest(),
new \DateTimeZone('Europe/London'),
),
// Interval, measured from the first run.
RecurringMessage::every('10 minutes', new SyncPrices()),
// A hashed expression: once a day, at a minute and hour
// derived from the message, so instances do not all fire at
// the same second.
RecurringMessage::cron('#daily', new PruneAuditLog()),
)
// Remember where the schedule got to, so a restarted worker does
// not simply skip whatever fell due while it was gone.
->stateful($this->cache);
}
}When to use it
- Send a digest email at 03:07 every morning without touching the server's crontab.
- Poll a supplier feed every ten minutes using the handler you already wrote.
- Hold a 'did you finish signing up?' message for 24 hours with a delay stamp.
- Restart workers automatically after a deploy so they pick up new code.
More examples
The attribute shorthand
The shortest way to make one service run on a timetable: no message class, no handler, no provider. The attribute's first argument is the cron expression; schedule: names a schedule other than default, and transports: sends the task through a Messenger transport instead of running it in the scheduler worker.
// src/Scheduler/Task/PruneSessions.php
declare(strict_types=1);
namespace App\Scheduler\Task;
use App\Session\SessionPrunerInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
// Equivalent to adding RecurringMessage::cron('@daily', ...) to the default
// schedule by hand. jitter spreads the start over a random 0-300 seconds.
#[AsCronTask('@daily', timezone: 'Europe/London', jitter: 300)]
final class PruneSessions
{
public function __construct(private readonly SessionPrunerInterface $pruner)
{
}
public function __invoke(): void
{
$this->pruner->prune();
}
}Seeing what a schedule will do
debug:scheduler is how you check a cron expression without waiting for it. Note that nothing recurring happens unless a worker is consuming the scheduler transport: the component replaces crontab entries, it does not replace the process that runs them.
# List every schedule and the next run of each message.
php bin/console debug:scheduler
# One schedule, as it would look on a given date.
php bin/console debug:scheduler default --date=2026-01-01
# Run the schedule. The transport name is scheduler_ plus the schedule name.
php bin/console messenger:consume scheduler_default -vv
# Every scheduler transport at once, by regular expression.
php bin/console messenger:consume 'scheduler_.*'Keeping the worker running
Supervisor starts two workers and restarts them whenever they exit, which they are meant to do. After deploying new code run php bin/console messenger:stop-workers: it sets a flag that makes each worker finish its current message and exit, and Supervisor brings it back on the new codebase. On a multi-host setup the application cache must be shared for that flag to reach every worker.
;/etc/supervisor/conf.d/messenger-worker.conf
[program:messenger-consume]
command=php /var/www/app/bin/console messenger:consume async --time-limit=3600
user=www-data
numprocs=2
startsecs=0
autostart=true
autorestart=true
; Each retry waits a second longer, so 10 covers a short deploy.
startretries=10
process_name=%(program_name)s_%(process_num)02d
Discussion