Symfony Scheduler: Recurring Messages, Workers, and Monitoring
Contents
Symfony Scheduler runs recurring messages only while a Messenger worker consumes the schedule's transport, so production needs php bin/console messenger:consume scheduler_default kept alive by Supervisor, systemd, or your container platform. There is no crontab entry: the worker works out when each RecurringMessage is due and handles it itself. When the worker stops, nothing runs and nothing is logged, and unless the schedule is stateful, runs that came due meanwhile are skipped, not made up.
This guide covers Symfony 6.4 LTS, 7.4 LTS, and 8.1, checked against the component's source at v6.4.48, v7.4.21, and v8.1.9 (the latest releases on October 25, 2026), the Scheduler documentation, and dragonmantank/cron-expression 3.6.1. The component arrived as experimental in 6.3; the attributes and events below need 6.4 (changelog). Laravel's scheduler works the other way round, from a cron entry that runs every minute (Laravel schedule not running).
How Symfony Scheduler Works#
A schedule is a service that implements ScheduleProviderInterface and carries #[AsSchedule], named default unless you pass a name. Its getSchedule() returns a Schedule of RecurringMessage objects, each a message plus a trigger: cron() takes a cron expression and an optional time zone, every() an interval such as '15 minutes'. Each schedule gets a Messenger transport named scheduler_<name>, and a worker consuming it generates each message when due and passes it to its handler.
Scheduler requires Messenger, and cron triggers require dragonmantank/cron-expression, which the component lists only as a development dependency. The schedule below also uses Lock:
composer require symfony/messenger symfony/scheduler symfony/lock dragonmantank/cron-expressionA message and its handler:
<?php
// src/Scheduler/Message/GenerateInvoices.php
namespace App\Scheduler\Message;
final class GenerateInvoices
{
}<?php
// src/Scheduler/Handler/GenerateInvoicesHandler.php
namespace App\Scheduler\Handler;
use App\Billing\InvoiceGenerator;
use App\Scheduler\Message\GenerateInvoices;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class GenerateInvoicesHandler
{
public function __construct(private readonly InvoiceGenerator $invoices)
{
}
public function __invoke(GenerateInvoices $message): void
{
$this->invoices->generateForYesterday();
}
}InvoiceGenerator stands for your own service. The schedule runs it at 06:00 New York time, with the settings production needs, explained below:
<?php
// src/Scheduler/MainSchedule.php
namespace App\Scheduler;
use App\Scheduler\Message\GenerateInvoices;
use Symfony\Component\Lock\LockFactory;
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;
#[AsSchedule('default')]
final class MainSchedule implements ScheduleProviderInterface
{
private ?Schedule $schedule = null;
public function __construct(
private readonly CacheInterface $cache,
private readonly LockFactory $lockFactory,
) {
}
public function getSchedule(): Schedule
{
return $this->schedule ??= (new Schedule())
->with(RecurringMessage::cron('0 6 * * *', new GenerateInvoices(), 'America/New_York'))
->stateful($this->cache) // remember the last run across restarts
->processOnlyLastMissedRun(true) // 7.2+ (drop on 6.4): catch up once, not per missed run
->lock($this->lockFactory->createLock('scheduler-default')); // one active worker
}
}If Flex created src/Schedule.php, add your messages there instead: the 7.2+ recipe already makes it stateful with processOnlyLastMissedRun(true), and since 7.3 a second provider under the same name throws an exception.
#[AsCronTask] and #[AsPeriodicTask]#
Since 6.4 you can skip the message class. Symfony adds a service carrying either attribute to the default schedule (or the one in schedule:) and calls __invoke(), or the method in method::
<?php
// src/Scheduler/Task/PurgeExpiredExports.php
namespace App\Scheduler\Task;
use App\Export\ExportStore;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('15 3 * * *', timezone: 'UTC')]
final class PurgeExpiredExports
{
public function __construct(private readonly ExportStore $exports)
{
}
public function __invoke(): void
{
$this->exports->deleteOlderThan(new \DateTimeImmutable('-30 days'));
}
}#[AsPeriodicTask(frequency: '1 hour')] is the interval form. On a console command, either attribute runs the command with the arguments: string, and a non-zero exit code fails the run (RunCommandMessageHandler). jitter: and transports: map to the options below.
Tasks Run Inside the Worker, One at a Time#
A slow task delays every task due after it, and afterwards a frequent task runs once for every occurrence it missed, back to back, unless the schedule uses processOnlyLastMissedRun(true) (MessageGenerator). To hand long tasks to ordinary workers, pass transports: 'async' or wrap the message in RedispatchMessage (docs).
A failed run in the scheduler worker isn't retried: Symfony gives retry strategies only to transports configured under framework.messenger.transports (FrameworkExtension), and scheduler_* transports are registered automatically. Messenger logs the error, and the next occurrence runs on schedule.
Keep the Worker Running#
Symfony's deployment guide puts workers under Supervisor or systemd:
; /etc/supervisor/conf.d/symfony-scheduler.conf
[program:symfony-scheduler]
command=php /var/www/app/bin/console messenger:consume scheduler_default --time-limit=3600 --memory-limit=256M
user=www-data
numprocs=1
autostart=true
autorestart=true
startsecs=0
startretries=10
stopwaitsecs=600- One process. Extra processes duplicate every run unless the schedule is locked.
- Stop gracefully. With the PCNTL extension, a worker that gets
SIGTERMfinishes its current message first. Supervisor sendsSIGKILLafterstopwaitsecs, 10 seconds by default (Supervisor docs), so set it above your longest task; under systemd, useRestart=alwaysand a long enoughTimeoutStopSec=(systemd.service). - Limit the lifetime only with a stateful schedule.
--time-limitand--memory-limitmake the worker exit so Supervisor starts a fresh one, which without saved state forgets when things last ran.
After each deploy, run php bin/console messenger:stop-workers so workers exit after their current message and restart on the new code. The stop flag is stored in a pool built on cache.app (cache.php), by default a filesystem cache inside var/cache/<env>/ (reference), under keys namespaced by a prefix_seed that defaults to _%kernel.project_dir%.%kernel.container_class% (reference). On several hosts, or when each release deploys into a new directory, the command writes the flag where running workers never look. A shared pool and a fixed seed fix that, and give stateful() durable storage:
# config/packages/cache.yaml
framework:
cache:
prefix_seed: 'billing-app/%kernel.environment%'
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'Otherwise restart workers through the process manager (supervisorctl restart symfony-scheduler).
Run the scheduler worker in one place. Starting it in every web container, or running other workers with messenger:consume --all (7.1+), which also consumes every scheduler_* transport (ConsumeMessagesCommand), multiplies every run; 7.4 adds --exclude-receivers.
Missed Runs: What stateful() Changes#
Without state, a worker counts from the moment it starts:
- A run that came due while no worker was running is skipped.
- A worker starting in the minute a cron run is due skips it: the cron library returns the next match strictly after the current minute (CronExpression.php).
every()without afromstarts its period at worker start (the docs' example: started at 8:33, an hourly task runs at 9:33, 10:33, and so on). Each restart resets the period, soevery('1 day')under--time-limit=3600never runs (PeriodicalTrigger).
stateful($cache) stores a checkpoint, the last run time and period start, and a restarted worker resumes from it, dispatching every occurrence it missed, or only the latest with processOnlyLastMissedRun(true) (7.2+). The checkpoint is saved after the handler returns (Checkpoint), so a worker killed mid-run repeats that run after restarting. Write handlers that can safely run twice.
An injected CacheInterface is cache.app, by default local to one server's var/cache; the Redis configuration above keeps the checkpoint across hosts and releases. Missed cron jobs after a reboot compares other schedulers.
Running Once: lock() and Multiple Workers#
Without a lock, every worker on a schedule dispatches every run. With lock(), one worker generates messages and the others wait as standbys, taking over if it stops. The lock covers the whole schedule, so extra workers add availability, not throughput (docs). Every worker must see the same store: the Flex recipe's LOCK_DSN=flock is local to one host (Lock stores), so several hosts need a remote store such as Redis, PDO, or PostgreSQL.
A worker that stops gracefully releases its lock at once. One that is killed (SIGKILL, the OOM killer) does not, and an expiring store such as Redis or PDO keeps the lock until its time to live runs out. After each run the scheduler extends that to the next run plus the lock's remaining lifetime (Checkpoint::release()), 300 seconds by default from createLock(), so no worker, not even the restarted one, takes over until after the next run was due. A stateful schedule then runs it late; a stateless one skips it. Use lock() and stateful() together; distributed cron covers the general patterns.
Time Zones, Jitter, and Hashed Expressions#
A cron trigger without a time zone uses PHP's default zone in the worker: whatever date_default_timezone_set() set, else date.timezone, else UTC (PHP manual). php bin/console about prints it, and the CLI can load a different php.ini from your web server's PHP (php --ini shows which). Pass the zone explicitly: the third argument to cron(), or timezone: on #[AsCronTask].
A local zone brings daylight saving time: one night a year has no 02:30 and another has two. Keep local-time tasks out of the hour the clocks change, or schedule them in UTC (cron daylight saving time).
withJitter(60), or jitter: 60, adds a random 0 to 60 seconds to each run (JitterTrigger). Hashed expressions such as '#midnight' pick a fixed time derived from the message's string form. php bin/console debug:scheduler lists each trigger with its resolved expression and next run time, including the UTC offset.
Symfony Scheduler Not Running: Checklist#
| Cause | How to confirm | Fix |
|---|---|---|
No worker consumes scheduler_default |
supervisorctl status; often only async is consumed |
Add a scheduler worker |
| The schedule isn't registered | debug:scheduler says No schedules found.; the worker fails with The receiver "scheduler_default" does not exist. |
Keep the #[AsSchedule] class autoconfigured |
| cron-expression is missing in production | The worker exits with ...the "cron expression" package is not installed, often after composer install --no-dev |
Move dragonmantank/cron-expression to require |
The worker restarts more often than every() |
every('1 day') without from, and a --time-limit under a day |
Use cron(), set from, or make the schedule stateful |
| The handler is missing or throws | messenger:consume scheduler_default -vv shows No handler for message or the exception |
Add #[AsMessageHandler]; fix the error |
| A killed worker's lock hasn't expired | Workers run, nothing dispatches, the lock key is still in the store | Wait for expiry; stop workers gracefully |
| It runs at the wrong hour | Timezone in bin/console about; offsets in debug:scheduler |
Pass the zone to the trigger |
Monitor Each Recurring Message With CronRadar#
Nothing in Symfony reports a run that didn't happen; an outside monitor that expects a check-in from every run does. With CronRadar that check-in is HTTP: there's no CronRadar SDK you can install for PHP or Symfony today (checked September 20, 2026), and nothing discovers Symfony schedules, so each recurring message reports with its own check-in to its own monitor. Monitoring Laravel scheduled tasks shows the same approach for Laravel's scheduler.
Store the key as CRONRADAR_API_KEY in your secret configuration, with one CronRadar application and key per environment. This service, which needs symfony/http-client, sends a check-in with Basic authentication, gives up after five seconds, and logs instead of throwing, because an exception from a scheduler event listener would stop the worker:
<?php
// src/Monitoring/CronRadar.php
namespace App\Monitoring;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class CronRadar
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
#[Autowire(env: 'default::CRONRADAR_API_KEY')]
private readonly ?string $apiKey = null,
) {
}
/** @param array<string, string|int> $query */
public function report(string $monitorKey, string $event, array $query = []): void
{
if (null === $this->apiKey || '' === $this->apiKey) {
return;
}
try {
$response = $this->httpClient->request('POST', 'https://cron.life/api/v1/ping/'.rawurlencode($monitorKey).'/'.$event, [
'auth_basic' => [$this->apiKey, ''],
'query' => $query,
'timeout' => 5,
'max_duration' => 5,
]);
// Wait for the reply here, so errors land in this catch block and not in the response destructor.
if (300 <= $status = $response->getStatusCode()) {
$this->logger->warning('CronRadar {event} for {key} returned HTTP {status}', ['event' => $event, 'key' => $monitorKey, 'status' => $status]);
}
} catch (\Throwable $e) {
$this->logger->warning('CronRadar {event} for {key} failed: {error}', ['event' => $event, 'key' => $monitorKey, 'error' => $e->getMessage()]);
}
}
}default:: makes a missing variable null instead of an error (env var processors). timeout is HttpClient's idle timeout and max_duration caps the whole request (HttpClientInterface); the query option URL-encodes the schedule.
Report From the Scheduler Events#
Since 6.4 the scheduler dispatches PreRunEvent before a scheduled message is handled, PostRunEvent after it succeeds, and FailureEvent when its handler throws (DispatchSchedulerEventListener). One subscriber reports every monitored task:
<?php
// src/Scheduler/CronRadarSubscriber.php
namespace App\Scheduler;
use App\Monitoring\CronRadar;
use App\Scheduler\Message\GenerateInvoices;
use Symfony\Component\Console\Messenger\RunCommandMessage;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Exception\HandlerFailedException;
use Symfony\Component\Scheduler\Event\FailureEvent;
use Symfony\Component\Scheduler\Event\PostRunEvent;
use Symfony\Component\Scheduler\Event\PreRunEvent;
use Symfony\Component\Scheduler\Generator\MessageContext;
use Symfony\Component\Scheduler\Messenger\ServiceCallMessage;
final class CronRadarSubscriber implements EventSubscriberInterface
{
// Task => [monitor key, schedule in UTC, grace period in seconds]
private const MONITORS = [
GenerateInvoices::class => ['invoice-generation', '0 10 * * *', 5400],
'@App\Scheduler\Task\PurgeExpiredExports' => ['purge-expired-exports', '15 3 * * *', 900],
];
public function __construct(private readonly CronRadar $cronRadar)
{
}
public static function getSubscribedEvents(): array
{
return [
PreRunEvent::class => ['onPreRun', -100], // after any listener that cancels the run
PostRunEvent::class => 'onPostRun',
FailureEvent::class => 'onFailure',
];
}
public function onPreRun(PreRunEvent $event): void
{
if (!$event->shouldCancel()) {
$this->report($event->getMessage(), $event->getMessageContext(), 'start');
}
}
public function onPostRun(PostRunEvent $event): void
{
$this->report($event->getMessage(), $event->getMessageContext(), 'complete');
}
public function onFailure(FailureEvent $event): void
{
// Messenger wraps the handler's exception; report only the original class.
$error = $event->getError();
$cause = $error instanceof HandlerFailedException ? ($error->getPrevious() ?? $error) : $error;
$this->report($event->getMessage(), $event->getMessageContext(), 'fail', $cause::class);
}
private function report(object $message, MessageContext $context, string $event, ?string $error = null): void
{
// #[AsCronTask] services arrive as "@<service id>", console commands as their input.
$task = $message instanceof ServiceCallMessage || $message instanceof RunCommandMessage
? (string) $message
: $message::class;
if (null === $monitor = self::MONITORS[$task] ?? null) {
return;
}
[$key, $schedule, $grace] = $monitor;
$query = [
'schedule' => $schedule,
'gracePeriod' => $grace,
'run_id' => $key.'-'.$context->triggeredAt->format('U'),
];
if (null !== $error) {
$query['message'] = $key.' threw '.$error;
}
$this->cronRadar->report($key, $event, $query);
}
}The run ID comes from the occurrence's scheduled time, so start and finish match without shared state, and catch-up runs get their own. The task's exception is untouched; Messenger logs it as usual. For a message redispatched to a transport with retries, FailureEvent fires on every failed attempt, so an attempt that later succeeds still opens an incident, which then recovers. Reporting from the handler works too: start, the work in a try block, fail and a rethrow in catch, then complete.
Translate the Schedule to UTC#
CronRadar evaluates five-field cron expressions in UTC (schedule syntax). 06:00 in New York is 11:00 UTC in winter and 10:00 UTC in summer, so the monitor uses the earlier time, 0 10 * * *, with a grace period of at least an hour plus the usual run: 5,400 seconds for a run under 30 minutes. The cost is a warning for the hour before each winter run, and a missed summer run turning critical 90 minutes late. A task scheduled in UTC needs neither.
CronRadar doesn't accept shortcuts such as @daily, hashed # fields, or intervals. Send the expression debug:scheduler resolved, and use cron() for monitored tasks: an every() task without a fixed from drifts with each restart. When both day fields are restricted, the cron library runs on either match, like Linux cron, while CronRadar expects both; split such a task into two monitors. After the first check-in, compare CronRadar's next expected run with debug:scheduler.
What CronRadar Catches#
- Failed: a
failcheck-in opens an incident immediately, with the exception class in its message. - Missed: the overdue check runs once a minute. A monitor is
warningonce its expected time passes and becomescritical, with an incident, when it is past its grace period and at least two minutes late. That covers a stopped worker, an unregistered schedule, and a stale lock. - Stuck: a
startwith nocompleteorfailgoescriticalonce the next scheduled time plus the grace period passes with no other check-in. Every check-in moves that time, so the next run'sstartresets it, and a hang is caught only when the task then goes silent. CronRadar has no per-task maximum-runtime setting, and--time-limitis checked between messages, so it won't stop a hung handler. Give the handler's I/O its own timeouts, such as HttpClient'smax_durationor a database statement timeout, so a hang becomes an exception and afail. A fatal error such as exhausted memory skipsFailureEventand leaves astartwith no finish.
schedule and gracePeriod apply only when a check-in creates the monitor; change an existing one in the dashboard. Sending them every time recreates a deleted monitor, but a monitor created by its first check-in can't flag a task that never ran; create it in the dashboard first for that. A daily task reveals a dead worker only once a day, so add a RecurringMessage::cron('*/5 * * * *', ...) heartbeat with an empty handler to MONITORS, with a grace period that covers your longest task. Alerts go to email, Slack, a webhook, Discord, or PagerDuty. Let one real run happen and confirm its start and complete arrive.
Frequently Asked Questions#
Why is my Symfony scheduler not running?#
Usually no process consumes the schedule's transport: production needs php bin/console messenger:consume scheduler_default running under a process manager. If a worker runs, check that debug:scheduler lists the message, that dragonmantank/cron-expression is installed in production, that restarts aren't resetting an every() interval, and the trigger's time zone.
Do I still need cron with Symfony Scheduler?#
Not for the schedule itself. Symfony Scheduler has no crontab entry; it needs a long-running Messenger worker kept alive by a process manager. Shared hosts that forbid long-running processes can start a short-lived worker from cron each minute (messenger:consume scheduler_default --time-limit=55; cPanel cron jobs covers PHP under cron), but only with a stateful, locked schedule: otherwise each worker skips the runs due in the minute it starts.
How do I prevent Symfony Scheduler from running a task twice?#
Run one worker per schedule and add ->lock() with a lock store every worker can reach, so standbys wait instead of dispatching duplicates. Keep messenger:consume --all and extra replicas off scheduler_* transports. A stateful schedule still repeats a run interrupted by a crash, so keep handlers idempotent.
Does Symfony Scheduler catch up missed runs?#
Only with ->stateful($cache). A stateful schedule dispatches the runs it missed while no worker ran, all of them by default, or only the latest with processOnlyLastMissedRun(true) on Symfony 7.2+. The Flex recipe's src/Schedule.php enables both; the cache must survive restarts and deploys.
How do I monitor Symfony scheduled tasks?#
debug:scheduler and messenger:consume -vv show what should run and what the worker does, but neither alerts you when a run is missing. Send a check-in per recurring message from the scheduler events, or from the handler, to a monitor that expects it on schedule. CronRadar works this way over HTTP; it has no Symfony SDK.
Put this guide to work
Connect your PHP job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The PHP SDK is temporarily unavailable on Packagist. Use manual HTTP reporting below. This setup does not discover jobs automatically.