Scheduling and Workflows

Recurring work with the Scheduler component, one-off delays with a stamp, and a process manager that keeps the worker alive.

SyntaxRecurringMessage::cron(string $expression, object $message, \DateTimeZone|string|null $timezone = null): RecurringMessage

Some 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

Example · php
// 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.

Example · php
// 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.

Example · bash
# 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.

Example · ini
;/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

  • Be the first to comment on this lesson.