symfony/scheduler
Schedule recurring tasks through Symfony Messenger with the Scheduler component. Define cron-based schedules, dispatch messages automatically, and integrate cleanly with your existing Symfony applications.
Laravel already ships a scheduler (routes/console.php / ->daily(), ->withoutOverlapping(), onOneServer()). symfony/scheduler is worth pulling in only when:
If none of those apply, stop here and use Illuminate\Console\Scheduling\Schedule. Do not run both schedulers over the same task.
composer require "symfony/scheduler:^8.1.9" symfony/messenger symfony/cache
Define messages as plain PHP objects — not Laravel jobs, not Artisan commands.
final class SendDailyReport
{
public function __construct(public readonly string $report = 'daily') {}
}
Define a schedule provider returning a Schedule (not an array):
namespace App\Schedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
final class AppSchedule implements ScheduleProviderInterface
{
public function __construct(private \Psr\Cache\CacheItemPoolInterface $cache) {}
public function getSchedule(): Schedule
{
return (new Schedule())
->stateful($this->cache) // enable checkpoints
->timezone('UTC')
->add(RecurringMessage::cron('0 0 * * *', new SendDailyReport()))
->add(RecurringMessage::every('2 hours', new SyncInventory()))
->add(RecurringMessage::every('1 day', new ReindexSearch(), from: new \DateTimeImmutable('2026-06-01 03:00')));
}
}
Register it — autoconfigured in Symfony via ScheduleProviderInterface; in a Laravel package you must bind it yourself in a service provider (see Gotchas).
Consume the generated scheduler transport (one per schedule name, default default):
php artisan tinker # or in the Symfony app: php bin/console
bin/console messenger:consume scheduler_default -vv
Verify before shipping:
bin/console debug:scheduler # list schedules, messages, next run times
bin/console debug:scheduler --date="+1 day"
Since 8.1.9 the task description resolves the message inside the Envelope, so the listed name is your message class/handler, not the transport envelope wrapper.
Schedule a report that must not double-fire after a deploy/restart:
$schedule = (new Schedule())
->stateful($cache) // survives worker restarts
->lock($lockFactory) // no overlapping runs across hosts
->processOnlyLastMissedRun(true) // don't replay 20 missed runs
->add(RecurringMessage::cron('0 0 * * *', new SendDailyReport()));
RecurringMessage pairs a message with a trigger. Build schedules out of these — don't hand-construct triggers unless you need a custom one.
RecurringMessage::cron('*/15 * * * *', new PollInbox());
RecurringMessage::every('10 minutes', new FlushMetrics(), from: new \DateTimeImmutable('now'));
RecurringMessage::every('1 week', new PruneLogs(), until: new \DateTimeImmutable('2026-12-31'));
Trigger classes underneath, when you need them: CronExpressionTrigger, PeriodicTrigger (takes a \DateInterval), DateTimeTrigger, CallbackTrigger.
Messages are routed through Messenger to handlers. If you're bridging into Laravel, the handler is the seam where you call Laravel code:
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
final class SendDailyReportHandler
{
public function __construct(private ReportService $reports) {}
#[AsMessageHandler]
public function __invoke(SendDailyReport $message): void
{
$this->reports->build($message->report);
// or: Artisan::call('app:send-report', ['--force' => true]);
}
}
Keep handlers thin; keep the message a small serializable DTO. Everything in the message gets serialized into the transport/checkpoint store.
new Schedule()): recomputed at boot, no cache dependency, will fire on every matching tick. Good for idempotent polling.->stateful($cache)): writes checkpoints so every()/cron() know the last run. Required for from/until and for not replaying missed runs. Requires a persistent PSR-6 pool (Redis, database, filesystem) — an array pool is useless across worker restarts.Use stateful for anything where a duplicate run costs money or sends a user two emails.
$schedule->lock($lockFactory); // Symfony Lock component
Pair with ->processOnlyLastMissedRun(true) for tasks that shouldn't be caught up in bulk. This is the equivalent of Laravel's withoutOverlapping()->onOneServer(), so don't reimplement it in the handler.
Behavior changed in 8.1.9 (#66573):
processOnlyLastMissedRun()no longer swallows every missed run after a worker restart — it now collapses to the last missed run as documented. If you carry a workaround (disabling the flag, or a compensating "run once on boot" message), remove it and retest.
When N tenants all schedule "midnight", spread them:
new RecurringMessage(new JitterTrigger(/* ... */), new SyncTenant($id));
or keep a CronExpressionTrigger and add per-tenant offset minutes. Cheaper than tuning the database.
Run messenger:consume scheduler_default under Supervisor/systemd as a long-lived daemon, alongside (not instead of) your queue:work:
php artisan queue:work --queue=default,high
php bin/console messenger:consume scheduler_default --time-limit=3600 --memory-limit=256M -vv
For Laravel-only deployments where you can't run a second daemon, the scheduler transport can be consumed at the tail of your existing worker, but you lose isolation — prefer a dedicated worker.
The schedule is resolved once at worker boot. Any change to the provider (new message, changed cron, feature flag) requires a worker restart. Put the restart in your deploy script and in php artisan queue:restart-equivalent hooks.
debug:scheduler for "what's next / did it fire". Since 8.1.9 the description unwraps the Envelope (#66781), so output names the real message rather than the transport wrapper — good for grep-friendly dashboards and for asserting output in tests, but adjust any snapshot/regex that matched the old envelope-prefixed string.PreRunEvent / PostRunEvent listeners for timing and failure hooks — note the scheduler does not retry failed messages like a normal transport; wrap handler bodies in your own try/catch + logging if you need retries.Log::channel('scheduler') and alert on "no checkpoint written in N hours" for stateful schedules.Checkpoint write failures (fixed in 8.1.8 — stay on ^8.1.9)
Prior to 8.1.8, a stateful schedule whose checkpoint could not be persisted (cache eviction, Redis blip, read-only filesystem, exhausted pool) would skip every subsequent run — silently. The fix (bug #66345, @srebb) makes the schedule tolerate an unsaveable checkpoint. Action items:
"symfony/scheduler": "^8.1.9" in composer.json.bin/console cache:pool:clear scheduler_checkpoints (or flush your cache.app pool). A checkpoint written by a broken path will otherwise linger.stateful() at an ephemeral pool.processOnlyLastMissedRun() skipped every missed run (fixed in 8.1.9 — re-test)
Before 8.1.9 (#66573, @lazerg), a stateful schedule using processOnlyLastMissedRun(true) could, after a restart, skip all pending missed runs instead of collapsing to the last one. It failed closed, so you saw silence rather than a burst — the worst kind of failure to notice. If you mitigated this by turning the flag off (and accepting a catch-up storm) or by seeding checkpoints manually on boot, undo that and verify:
# stop worker, let a few runs pass, restart, then inspect
bin/console debug:scheduler --date="-2 hours"
Keep the regression test: assert one dispatch, not zero and not N, after simulating elapsed time + restart.
Ephemeral cache = silently stateless
->stateful() with an array/dev-null pool in a long-running worker works until the worker restarts, then re-fires everything from from. Verify with debug:scheduler after a forced restart before you call it done.
ScheduleProviderInterface::getSchedule() returns Schedule, not an array
If you're porting code or following older blog posts, return [new CronTrigger(...)] will fatal. Messages go through RecurringMessage, not through trigger constructors.
Laravel has no Symfony DI container
ScheduleProviderInterface autoconfiguration (tags, scheduler.schedule_provider) does not exist in Laravel. You must resolve the provider manually from a service provider and hand the Schedule to the consumer/transport. This is the main reason most Laravel apps should not adopt it. Don't mix schedule:run with a scheduler worker — pick one owner per task or you'll get duplicates.
Long-lived worker + dynamic schedules
Feature-flag logic inside getSchedule() is evaluated at boot only. Changing the flag does nothing until restart. If you need true runtime dynamism, schedule a coarse "dispatch what's due now" message that reads the DB in the handler.
Timezones
CronExpressionTrigger uses PHP's default TZ unless told otherwise. Set ->timezone('UTC') on the Schedule (or pass a \DateTimeZone to the trigger) explicitly — DST boundaries on 0 2 * * * are real bugs in production.
No retry semantics A scheduler-dispatched message that throws is not retried by a transport retry strategy; the run is simply failed. Handle idempotency and retries in the handler, or re-dispatch onto a real async transport.
PeriodicTrigger is not a metronome
It fires on tick boundaries of the consumer, so intervals drift with processing time. Don't use 1-minute periodic triggers for anything timing-sensitive; use cron.
Pin and verify the version
composer show symfony/scheduler | grep versions
composer require "symfony/scheduler:^8.1.9"
8.1.8 → 8.1.9 is bugfix-only: no API changes, no code changes required, no deprecations. You get the missed-run collapse fix (#66573) and Envelope-aware task descriptions (#66781) for free. If you are jumping from 8.1.7 or earlier, do the one-time checkpoint pool clear listed above.
Test schedules without a worker Assert the shape of the schedule, not wall-clock behavior:
$schedule = (new AppSchedule($arrayCache))->getSchedule();
$this->assertCount(3, $schedule->getRecurringMessages());
For cron math, test the trigger's expression directly rather than sleeping.
Simulate missed runs and restarts
bin/console debug:scheduler --date="+3 days"
Confirms from/until/processOnlyLastMissedRun behave as intended before you rely on them. Always follow a --date check with an actual worker bounce — the 8.1.9 fix only matters across a restart, so a check that never restarts proves nothing.
Isolate the scheduler cache pool
Give checkpoints their own pool (e.g. scheduler_checkpoints) so a cache:clear on the app cache doesn't reset your schedules — and so you can flush it surgically after this upgrade.
Lazy-load inside the handler Heavy constructor dependencies on the schedule provider are resolved at boot and held for the worker's life. Construct the provider cheaply; do the work (HTTP clients, DB connections) in the handler.
Write one integration test per schedule
Boot the provider, resolve each RecurringMessage, dispatch its message by hand through the bus, and assert the side effect. This catches serialization regressions in message DTOs, which are the most common production break in Messenger-based schedulers.
Don't assert on debug:scheduler strings
The 8.1.9 Envelope fix changed what debug:scheduler prints for wrapped messages. Assert on getRecurringMessages() and message classes in tests; reserve the console output for humans.
How can I help you explore Laravel packages today?