# Symfony Scheduler: Recurring Messages, Workers, and Monitoring

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/symfony-scheduler

> How Symfony Scheduler runs recurring messages in a Messenger worker, why schedules stop running, and how stateful(), lock(), and check-ins keep tasks on time.

**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](https://github.com/symfony/scheduler/tags) (the latest releases on October 25, 2026), the [Scheduler documentation](https://symfony.com/doc/current/scheduler.html), and dragonmantank/cron-expression 3.6.1. The component arrived as experimental in 6.3; the attributes and events below need 6.4 ([changelog](https://github.com/symfony/scheduler/blob/v8.1.9/CHANGELOG.md)). Laravel's scheduler works the other way round, from a cron entry that runs every minute ([Laravel schedule not running](https://cronradar.com/blog/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](https://github.com/symfony/scheduler/blob/v8.1.9/composer.json). The schedule below also uses Lock:

```bash
composer require symfony/messenger symfony/scheduler symfony/lock dragonmantank/cron-expression
```

A message and its handler:

```php
<?php
// src/Scheduler/Message/GenerateInvoices.php
namespace App\Scheduler\Message;

final class GenerateInvoices
{
}
```

```php
<?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
<?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](https://github.com/symfony/recipes/blob/main/symfony/scheduler/7.2/src/Schedule.php) 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
<?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](https://github.com/symfony/console/blob/v8.1.9/Messenger/RunCommandMessageHandler.php)). `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](https://github.com/symfony/scheduler/blob/v8.1.9/Generator/MessageGenerator.php)). To hand long tasks to ordinary workers, pass `transports: 'async'` or wrap the message in `RedispatchMessage` ([docs](https://symfony.com/doc/current/scheduler.html#efficient-management-with-symfony-scheduler)).

A failed run in the scheduler worker isn't retried: Symfony gives retry strategies only to transports configured under `framework.messenger.transports` ([FrameworkExtension](https://github.com/symfony/framework-bundle/blob/v8.1.9/DependencyInjection/FrameworkExtension.php)), 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](https://symfony.com/doc/current/messenger.html#deploying-to-production) puts workers under Supervisor or systemd:

```ini
; /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 `SIGTERM` finishes its current message first. Supervisor sends `SIGKILL` after `stopwaitsecs`, 10 seconds by default ([Supervisor docs](http://supervisord.org/configuration.html)), so set it above your longest task; under systemd, use `Restart=always` and a long enough `TimeoutStopSec=` ([systemd.service](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html)).
- **Limit the lifetime only with a stateful schedule.** `--time-limit` and `--memory-limit` make 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](https://github.com/symfony/framework-bundle/blob/v8.1.9/Resources/config/cache.php)), by default a filesystem cache inside `var/cache/<env>/` ([reference](https://symfony.com/doc/current/reference/configuration/framework.html#directory)), under keys namespaced by a `prefix_seed` that defaults to `_%kernel.project_dir%.%kernel.container_class%` ([reference](https://symfony.com/doc/current/reference/configuration/framework.html#prefix-seed)). 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:

```yaml
# 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](https://github.com/symfony/messenger/blob/v8.1.9/Command/ConsumeMessagesCommand.php)), 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](https://github.com/dragonmantank/cron-expression/blob/v3.6.1/src/Cron/CronExpression.php)).
- `every()` without a `from` starts 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, so `every('1 day')` under `--time-limit=3600` never runs ([PeriodicalTrigger](https://github.com/symfony/scheduler/blob/v8.1.9/Trigger/PeriodicalTrigger.php)).

`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](https://github.com/symfony/scheduler/blob/v8.1.9/Generator/Checkpoint.php)), 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](https://cronradar.com/blog/missed-runs-after-downtime) 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](https://symfony.com/doc/current/scheduler.html#efficient-management-with-symfony-scheduler)). Every worker must see the same store: the Flex recipe's `LOCK_DSN=flock` is local to one host ([Lock stores](https://symfony.com/doc/current/lock.html#available-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()](https://github.com/symfony/scheduler/blob/v8.1.9/Generator/Checkpoint.php)), 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](https://cronradar.com/blog/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](https://www.php.net/manual/en/function.date-default-timezone-get.php)). `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](https://cronradar.com/blog/cron-daylight-saving-time)).

`withJitter(60)`, or `jitter: 60`, adds a random 0 to 60 seconds to each run ([JitterTrigger](https://github.com/symfony/scheduler/blob/v8.1.9/Trigger/JitterTrigger.php)). 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](https://cronradar.com/blog/laravel-cron-monitoring) 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
<?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](https://symfony.com/doc/current/configuration/env_var_processors.html)). `timeout` is HttpClient's idle timeout and `max_duration` caps the whole request ([HttpClientInterface](https://github.com/symfony/http-client-contracts/blob/v3.7.3/HttpClientInterface.php)); 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](https://github.com/symfony/scheduler/blob/v8.1.9/EventListener/DispatchSchedulerEventListener.php)). One subscriber reports every monitored task:

```php
<?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](https://docs.cronradar.com/schedules)). 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 `fail` check-in opens an incident immediately, with the exception class in its message.
- **Missed:** the overdue check runs once a minute. A monitor is `warning` once its expected time passes and becomes `critical`, 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 `start` with no `complete` or `fail` goes `critical` once the next scheduled time plus the grace period passes with no other check-in. Every check-in moves that time, so the next run's `start` resets it, and a hang is caught only when the task then goes silent. CronRadar has no per-task maximum-runtime setting, and `--time-limit` is checked between messages, so it won't stop a hung handler. Give the handler's I/O its own timeouts, such as HttpClient's `max_duration` or a database statement timeout, so a hang becomes an exception and a `fail`. A fatal error such as exhausted memory skips `FailureEvent` and leaves a `start` with 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](https://cronradar.com/blog/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.
