Symfony Scheduler — Tâches récurrentes natives (6.3+)
TL;DR —
symfony/scheduler(introduit en 6.3, stabilisé en 6.4 LTS) est une couche au-dessus de Messenger qui transforme des messages PHP en tâches récurrentes. Vous définissez unRecurringMessage(cron, intervalle, ou trigger custom) dans unScheduleProviderInterface, puis vous lancez un consumer Messenger sur le transportscheduler://. Plus besoin decronsystème qui exécute desbin/console app:digestà intervalles fixes : tout vit dans le code PHP, versionné, testable, déployable en blue/green. Pour le distributed scheduling (cluster), un lock (symfony/lock) sur l'exécution évite la duplication. La frontière avec Kubernetes CronJob / crond système devient une question de granularité opérationnelle plus que de capacité technique.
🧠 Mental model — ASCII + analogie
Le Scheduler est un chef d'orchestre qui regarde sa montre. À l'heure dite, il fait un signe au transport Messenger qui envoie le message à la file. Un worker (le musicien) lit la file et exécute le handler. La beauté : le scheduler lui-même est un transport Messenger, ce qui veut dire que la même boucle de consommation qui lit RabbitMQ ou Doctrine peut aussi lire le "transport scheduler".
Code (Schedule provider) Worker
┌──────────────────────┐ ┌──────────────────────────┐
│ RecurringMessage │ │ messenger:consume │
│ - cron "0 9 * * *" │ │ scheduler_default │
│ - new DigestEmail() │ │ │
│ RecurringMessage │ ───► │ while (true) { │
│ - every "5 minutes" │ │ msg = transport.get │
│ - new HealthCheck() │ │ handler(msg) │
└──────────────────────┘ │ } │
▲ └──────────────────────────┘
│
scheduler:// transport
(clock + triggers)Analogie : c'est une horloge à coucou programmable. Là où crond fait coucou à 9h en exécutant un binaire externe, Scheduler fait coucou en envoyant un message dans une file que vos workers consomment déjà. Le bénéfice principal n'est pas la cron-syntax (que crond a aussi), c'est que les jobs schedulés réutilisent toute la stack Messenger : retry, dead letter, middleware, transactions, observabilité.
🛠️ Code minimal (PHP 8.2+)
Installation
composer require symfony/scheduler symfony/messengerDéfinir un message + handler
<?php
declare(strict_types=1);
namespace App\Scheduler\Message;
final readonly class SendDailyDigest
{
public function __construct(public \DateTimeImmutable $for) {}
}<?php
namespace App\Scheduler\Handler;
use App\Scheduler\Message\SendDailyDigest;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Psr\Log\LoggerInterface;
#[AsMessageHandler]
final readonly class SendDailyDigestHandler
{
public function __construct(
private DigestComposer $composer,
private MailerInterface $mailer,
private LoggerInterface $logger,
) {}
public function __invoke(SendDailyDigest $msg): void
{
$this->logger->info('Sending daily digest', ['for' => $msg->for->format('Y-m-d')]);
foreach ($this->composer->composeForDate($msg->for) as $email) {
$this->mailer->send($email);
}
}
}Schedule provider
<?php
namespace App\Scheduler;
use App\Scheduler\Message\SendDailyDigest;
use App\Scheduler\Message\HealthPing;
use App\Scheduler\Message\WeeklyReport;
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 readonly class AppSchedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
private LockFactory $lockFactory,
) {}
public function getSchedule(): Schedule
{
return (new Schedule())
->add(
RecurringMessage::cron('0 9 * * *', new SendDailyDigest(new \DateTimeImmutable('today'))),
RecurringMessage::cron('0 8 * * MON', new WeeklyReport()),
RecurringMessage::every('5 minutes', new HealthPing()),
)
->stateful($this->cache) // mémorise la dernière exécution pour rattraper après downtime
->lock($this->lockFactory->createLock('scheduler-default')); // exécution unique en cluster
}
}Note d'injection —
AsScheduleest instancié par le container DI : ses dépendances (CacheInterface,LockFactory,ClockInterface) sont autowirées. LeScheduleest mémoïsé par le composant :getSchedule()n'est appelé qu'une fois par cycle de vie du worker. C'est pourquoi un schedule « dynamique » (lecture en base) ne se rafraîchit qu'au redémarrage du worker — voir le pattern Generator scheduling.
Démarrer le worker
php bin/console messenger:consume scheduler_default -vvEn production, on ajoute un transport secondaire pour que le handler ne bloque pas la boucle du scheduler :
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Scheduler\Message\SendDailyDigest': async
'App\Scheduler\Message\WeeklyReport': async
# HealthPing reste sync pour la rapidité# Process 1 : scheduler qui produit
php bin/console messenger:consume scheduler_default
# Process 2..N : workers métier
php bin/console messenger:consume async --limit=200 --time-limit=3600Trigger personnalisé
Le TriggerInterface permet des règles non-cron (ex. "tous les jours ouvrés à 9h sauf jours fériés FR").
use Symfony\Component\Scheduler\Trigger\TriggerInterface;
final class BusinessDayMorningTrigger implements TriggerInterface
{
public function __construct(private HolidayCalendar $cal) {}
public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable
{
$next = $run->modify('+1 day')->setTime(9, 0);
while ($this->cal->isHoliday($next) || in_array($next->format('N'), ['6', '7'], true)) {
$next = $next->modify('+1 day');
}
return $next;
}
public function __toString(): string { return 'business-day-morning'; }
}RecurringMessage::trigger(
new BusinessDayMorningTrigger($this->holidayCalendar),
new SendDailyDigest(new \DateTimeImmutable('today')),
);Piège de signature —
getNextRunDate(\DateTimeImmutable $run)reçoit la dernière date d'exécution (ou « maintenant » au premier tick) et doit renvoyer la prochaine strictement future. Si vous renvoyez une date dans le passé ou égale à$run, le composant peut boucler ou ne jamais déclencher. Toujoursmodify('+1 …')avant de chercher.
Décorateurs de trigger fournis (Symfony 6.4+/7.x)
N'écrivez pas un trigger custom quand un décorateur natif suffit. Ils enveloppent un trigger existant :
use Symfony\Component\Scheduler\Trigger\CronExpressionTrigger;
use Symfony\Component\Scheduler\Trigger\JitterTrigger;
// Cron + jitter aléatoire jusqu'à 60s : évite le « thundering herd »
// quand 500 tenants ont tous une tâche à 9h00 pile.
RecurringMessage::trigger(
new JitterTrigger(CronExpressionTrigger::fromSpec('0 9 * * *'), maxSeconds: 60),
new SendDailyDigest(new \DateTimeImmutable('today')),
);Sucre syntaxique équivalent disponible directement sur RecurringMessage à partir de 6.4 :
RecurringMessage::cron('0 9 * * *', new SendDailyDigest(new \DateTimeImmutable('today')))
->withJitter(60); // jitter natif
RecurringMessage::cron('#midnight', new Cleanup()); // alias macro façon crontab
RecurringMessage::cron('0 9 * * *', new Report(), new \DateTimeZone('Europe/Paris')); // TZ par messageLes alias hash (#midnight, #hourly, #daily, #weekly, #monthly, #yearly, #annually) dérivent une minute/heure stable mais répartie à partir du hash du message — deux tâches #daily ne tomberont pas à la même seconde. C'est le jitter « gratuit » recommandé pour les tâches multi-tenant.
🎯 Patterns courants
1. Digest quotidien / hebdomadaire
Pattern le plus naturel pour Scheduler. Cron 0 9 * * * → un message SendDailyDigest poussé vers le transport async. Les workers traitent en parallèle. Idempotency : le handler vérifie digest_log table pour ne pas envoyer deux fois si rejoué.
2. Health ping interne
RecurringMessage::every('30 seconds', new HealthPing()) qui exécute des checks (DB up, Redis up, espace disque). Le résultat est publié dans Prometheus / Datadog. Avantage vs. healthcheck HTTP externe : pas de dépendance à un cron-runner externe, le check vit avec l'app.
3. Rappel de tâche métier (reminders à venir)
Pour des rappels "dans 7 jours" non récurrents (un seul tir), Scheduler n'est pas le bon outil : utilisez MessageBus::dispatch avec un DelayStamp(7 * 86400 * 1000). Scheduler est dédié aux récurrences.
4. Rapport hebdo lourd avec sharding
Un cron 0 4 * * MON envoie WeeklyReportShardCommand(shard: 0, total: 8) etc. Les 8 messages sont consommés en parallèle. Évite de bloquer un worker pendant 2h.
foreach (range(0, 7) as $shard) {
$bus->dispatch(new WeeklyReportShardCommand($shard, 8));
}5. Re-scheduling après échec (retry policy spécifique)
Scheduler hérite des retries Messenger. Mais attention : si SendDailyDigest échoue 3 fois, il finit en dead letter ; le scheduler ne le rejouera pas automatiquement, il enverra simplement le message du jour suivant. Pour rattraper un échec, soit on rejoue manuellement (messenger:failed:retry), soit on conçoit le handler idempotent et on attend la prochaine exécution.
6. Stateful catch-up (rattrapage après downtime)
->stateful($cache) permet au scheduler de mémoriser la dernière exécution. Si le worker était down à 9h et redémarre à 9h17, il rejoue le 9h. À utiliser avec parcimonie : pour un HealthPing toutes les 30s, vous risquez d'envoyer 200 messages d'un coup si vous redémarrez après 2h de panne. Désactiver pour les tâches haute fréquence.
7. Distributed scheduling (cluster)
->lock($lockFactory->createLock(...)) garantit qu'un seul nœud du cluster exécute le scheduler à un instant T. Le store du lock doit être partagé (Redis, Doctrine). Sans cela, N nœuds = N envois du même message.
8. Generator scheduling (jobs créés dynamiquement)
Le ScheduleProviderInterface peut retourner des RecurringMessage calculés dynamiquement (lecture d'une table scheduled_tasks en base). Régénération automatique avec Schedule::processOnlyLastMissedRun() ou en redémarrant le worker à chaque changement.
public function getSchedule(): Schedule
{
$schedule = new Schedule();
foreach ($this->scheduleRepository->findAllActive() as $task) {
$schedule->add(
RecurringMessage::cron(
$task->getCronExpression(),
new RunCustomTask($task->getId()),
),
);
}
return $schedule;
}9. Scheduler en multi-tenant
Pour un SaaS où chaque tenant a son propre fuseau horaire et ses propres rapports : produire un seul RecurringMessage::every('1 hour', new CheckAllTenants()) qui à chaque exécution lit les tenants éligibles ET envoie un message par tenant éligible. Ne pas créer un RecurringMessage par tenant : la liste serait recalculée à chaque tick du scheduler, coûteux et fragile.
10. Retry policies fines
framework:
messenger:
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000 # 1s
multiplier: 2 # 1s, 2s, 4s
max_delay: 60000Pour un message issu du scheduler, ces retries s'appliquent uniquement au handler, pas au déclenchement. Si vous voulez retry au niveau scheduler (rejouer la cron à l'heure suivante en cas d'échec), il faut le coder dans le ScheduleProviderInterface (avec un stateful() qui interroge un journal d'exécutions).
11. Combiner avec symfony/lock pour leadership distribué
Voir section "Distributed scheduling" plus haut. Le pattern complet en production cluster :
public function getSchedule(): Schedule
{
return (new Schedule())
->add(
RecurringMessage::cron('0 9 * * *', new SendDailyDigest(new \DateTimeImmutable())),
)
->stateful($this->cache)
->lock($this->lockFactory->createLock('scheduler-default'));
}Le stateful mémorise les exécutions ; le lock évite la concurrence inter-nœuds. Les deux sont nécessaires pour un cluster.
12. Debug en local
php bin/console debug:scheduler # depuis 7.2+, liste les schedules
php bin/console messenger:consume scheduler_default -vvv # logs verbeuxEn dev, vous pouvez forcer une exécution immédiate en démarrant le worker juste après le moment programmé, ou en mockant le ClockInterface.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
| Version Symfony | Scheduler dispo | Notes |
|---|---|---|
| 5.4 LTS | Non | Pas de composant scheduler. Solutions tierces : loevgaard/cron-bundle, crond système. |
| 6.3 | Expérimental | API marquée @experimental. Évolutions de signature possibles. |
| 6.4 LTS | Stable | API gelée. AsSchedule, `RecurringMessage::cron |
| 7.0+ | Stable amélioré | Ajouts : ConsumeMessagesCommand plus verbeux, meilleur support des Clock mockés pour tests, événements PreRunEvent/PostRunEvent pour observabilité. |
| 7.1 | Améliorations | Le scheduler peut désormais émettre des messages plusieurs fois par seconde sans surcharge (boucle interne optimisée). |
| 7.2+ | Améliorations | Support des time zones par schedule (->timezone('Europe/Paris')), debug command debug:scheduler. |
Pour Symfony 5.4, la migration la plus naturelle vers Scheduler est : (1) garder crond pour les jobs critiques, (2) introduire Messenger, (3) upgrader vers 6.4 LTS, (4) basculer progressivement chaque cron vers un RecurringMessage.
⚠️ Pitfalls — 6-10
Worker du scheduler arrêté = pas d'exécutions. Beaucoup pensent "j'ai défini ma cron en PHP, ça suffit". Non : il faut un processus
messenger:consume scheduler_defaultpermanent (systemd, supervisord, k8s deployment). Sans lui, rien ne tourne.Deux instances du scheduler sans lock = doublons. En cluster, sans
->lock(...), chaque pod envoieSendDailyDigest. Vos utilisateurs reçoivent 3 emails. Toujours locker en prod multi-instance.Cron expression incorrecte.
* * * * * *(6 champs) n'existe pas dans le Scheduler Symfony (il utilise le format 5 champs standardmn h jmois mois jsem). Validez vos expressions viadragonmantank/cron-expressionou un site comme crontab.guru.Time zone implicite UTC. Sans
->timezone(...)(Symfony 7.2+) ou sans configuration de timezone PHP, "0 9 * * *" tourne à 9h UTC = 10h Paris hiver / 11h Paris été ⇒ décalage saisonnier inattendu.Handler bloquant la boucle scheduler. Si vous ne routez pas les messages vers un transport async, le handler s'exécute dans le même process que le scheduler ⇒ pendant qu'il envoie 50 000 emails, aucune nouvelle cron ne se déclenche. Toujours router les jobs lourds vers
async.stateful()sur tâche haute fréquence + cache shared. Si le cache stateful est partagé entre instances mais le lock ne l'est pas, vous obtenez des comportements aléatoires. Lock store et stateful store doivent être cohérents.every('30 seconds')avec un handler qui prend 35 s. Si le handler est sync, vous accumulez du retard. Si async, vous remplissez la file plus vite que vous ne la videz. Surveillez la profondeur de file et la durée d'exécution.Migration depuis crond avec PATH différent. Le crond système charge un environnement minimal ; le scheduler Symfony hérite de l'environnement du worker (souvent plus riche). Des scripts qui marchaient avec crond peuvent dépendre d'un PATH absent, et inversement.
Oublier
--time-limitsur le worker. Un workermessenger:consume scheduler_defaultqui tourne 30 jours sans recyclage accumule des fuites mémoire (PDO, EM Doctrine). Toujours--time-limit=3600+ supervisord qui relance.Pas de monitoring. Un cron qui ne se déclenche pas est silencieux. Branchez un "canari" :
every('1 minute', new Heartbeat())qui écrit dans Redis/StatsD, et un alerting Prometheus si la métrique disparaît plus de 3 min.
🧪 Testing
Test unitaire d'un schedule provider
<?php
namespace App\Tests\Scheduler;
use App\Scheduler\AppSchedule;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\InMemoryStore;
final class AppScheduleTest extends TestCase
{
public function testScheduleContainsDailyDigestAt9amUtc(): void
{
$cache = new ArrayAdapter();
$lockFactory = new LockFactory(new InMemoryStore());
$provider = new AppSchedule($cache, $lockFactory);
$schedule = $provider->getSchedule();
$messages = iterator_to_array($schedule->getRecurringMessages());
$crons = array_map(fn ($m) => (string) $m->getTrigger(), $messages);
$this->assertContains('0 9 * * *', $crons);
}
}Test du handler
final class SendDailyDigestHandlerTest extends TestCase
{
public function testSendsOneEmailPerSubscriber(): void
{
$composer = $this->createMock(DigestComposer::class);
$composer->method('composeForDate')->willReturn([
new \Symfony\Component\Mime\Email(),
new \Symfony\Component\Mime\Email(),
]);
$mailer = $this->createMock(MailerInterface::class);
$mailer->expects($this->exactly(2))->method('send');
$handler = new SendDailyDigestHandler($composer, $mailer, new \Psr\Log\NullLogger());
$handler(new SendDailyDigest(new \DateTimeImmutable('2026-01-15')));
}
}Test du timing avec Clock
use Symfony\Component\Clock\MockClock;
$clock = new MockClock('2026-01-15 08:59:00');
$trigger = new CronExpressionTrigger('0 9 * * *');
$next = $trigger->getNextRunDate($clock->now());
$this->assertSame('2026-01-15 09:00:00', $next->format('Y-m-d H:i:s'));
$clock->sleep(60);
$next2 = $trigger->getNextRunDate($clock->now());
$this->assertSame('2026-01-16 09:00:00', $next2->format('Y-m-d H:i:s'));Test d'un trigger personnalisé
public function testBusinessDayMorningSkipsWeekends(): void
{
$cal = $this->createMock(HolidayCalendar::class);
$cal->method('isHoliday')->willReturn(false);
$trigger = new BusinessDayMorningTrigger($cal);
// Vendredi → suivant doit être lundi
$friday = new \DateTimeImmutable('2026-01-16 09:00:00');
$next = $trigger->getNextRunDate($friday);
$this->assertSame('Monday', $next->format('l'));
$this->assertSame('09:00:00', $next->format('H:i:s'));
}Test d'intégration avec scheduler en transport
# config/packages/test/messenger.yaml
framework:
messenger:
transports:
async: 'in-memory://'
scheduler_default: 'in-memory://'Test E2E avec transport in-memory
# config/packages/test/messenger.yaml
framework:
messenger:
transports:
async: 'in-memory://'// Le scheduler envoie sur scheduler_default, qui route vers in-memory
$this->commandTester->execute(['command' => 'messenger:consume', 'receivers' => ['scheduler_default'], '--limit' => 1]);
$transport = self::getContainer()->get('messenger.transport.async');
$this->assertCount(1, $transport->getSent());🎬 Cas d'usage concrets
Génération rapports comptables nocturnes
Un cabinet d'expertise comptable gère la compta de 800 PME clientes. Chaque mois, à la clôture, il faut générer pour chaque client un pack de rapports : balance générale, grand livre, état de TVA, prévisions de trésorerie, indicateurs clés. Historiquement, ce traitement nocturne était lancé via un cron système 0 2 1 * * php bin/console reports:generate-monthly qui itérait sur tous les clients dans un seul process. Inconvénients : 6 à 8 heures de traitement bloquant, aucun retry possible si un client échouait (un PDF mal généré bloquait les suivants), aucune visibilité sur l'avancement, et impossible de tester en local sans hacks. La nouvelle architecture utilise le Scheduler : RecurringMessage::cron('0 2 1 * *', new MonthlyReportsKickoff(new \DateTimeImmutable('first day of last month'))) déclenche un message kickoff qui dispatch ensuite un GenerateClientReport par client vers un transport async à 8 workers parallèles. Chaque rapport tourne 30 à 90 secondes, donc en parallélisme x8 on traite 800 clients en environ 90 minutes au lieu de 8 heures. Si un client échoue (DB indisponible, données corrompues), Messenger retry 3 fois en exponentiel, puis route vers la DLQ qu'un cabinet associé inspecte le matin. Les indicateurs Prometheus permettent au DSI de voir l'avancement en live. Bonus : le même message GenerateClientReport peut être déclenché manuellement à la demande (régénération sur demande client) — réutilisation 100% du code.
Rappels échéance cabinet d'avocats
Un cabinet d'avocats jongle entre des dizaines de procédures avec des échéances strictes : audiences, dépôts de conclusions, prescriptions, délais d'appel. Manquer une échéance peut signifier perdre une affaire et engager la responsabilité du cabinet. Le système de rappels actuel envoyait un mail le matin de l'échéance — trop tard pour préparer. La nouvelle version utilise le Scheduler avec un trigger métier custom : toutes les heures (RecurringMessage::every('1 hour', new CheckUpcomingDeadlines())), le handler interroge la base des échéances ouvertes et calcule des rappels J-7, J-3, J-1, J-0 selon la criticité (audience = J-7, J-3, J-1 et J-0 ; dépôt simple = J-1 et J-0). Pour chaque rappel à envoyer, un message SendDeadlineReminder est dispatché vers le transport notifications (8 workers email/SMS). Le handler vérifie qu'aucun rappel identique n'a déjà été envoyé pour ce binôme (échéance, niveau de rappel) via une contrainte unique en DB (idempotency). Les associés reçoivent les rappels critiques par SMS (Twilio via Notifier), les autres par email, et toute échéance est aussi pushée dans Slack via webhook. Pour les jours fériés et weekends, un BusinessDayTrigger évite d'envoyer un rappel le dimanche en l'avançant au vendredi. Depuis le déploiement, le cabinet n'a plus manqué une seule échéance, et les avocats ont remonté que la sensation de contrôle a transformé leur gestion du stress quotidien.
Sync stocks e-commerce multi-canaux
Un retailer e-commerce vend simultanément sur son site propre, sur Amazon Marketplace, sur Cdiscount, et en magasin physique (4 boutiques + entrepôt central). Les stocks doivent être réconciliés en quasi-temps réel pour éviter les survente : un produit vendu en magasin doit voir son stock baissé instantanément sur tous les canaux web. La solution combine deux niveaux de Scheduler. Niveau 1 : sync rapide toutes les 2 minutes (RecurringMessage::every('2 minutes', new SyncIncrementalStock())) qui récupère les deltas depuis la dernière sync (via timestamps) et propage vers les API marketplace. Niveau 2 : full reconciliation nocturne (RecurringMessage::cron('30 3 * * *', new FullStockReconciliation())) qui compare le stock théorique avec les inventaires réels et corrige les écarts (typiquement 1-2% de drift cumulé sur 24h). Le Scheduler est lancé sur 3 pods Kubernetes avec un lock distribué Redis (->lock(...)) garantissant qu'un seul pod produit les messages à la fois. Les messages sont consommés par des workers spécialisés par marketplace (chaque API a son rate limit, son format, sa logique de retry). En cas de panne d'une marketplace (Cdiscount down 3h), Messenger retry exponentiel et la sync reprend automatiquement, sans intervention humaine. Avant cette architecture : 4 personnes en backoffice à mi-temps pour gérer les stocks. Aujourd'hui : 1 personne à temps partiel pour valider les alertes d'écart suspect.
🛠️ Exemple end-to-end
Système de rappels d'échéances pour cabinet d'avocats : scheduler horaire, calcul des rappels à envoyer, dispatch async avec idempotency.
<?php
// src/Scheduler/Message/CheckUpcomingDeadlines.php
declare(strict_types=1);
namespace App\Scheduler\Message;
final readonly class CheckUpcomingDeadlines
{
public function __construct(public \DateTimeImmutable $at) {}
}<?php
// src/Scheduler/Message/SendDeadlineReminder.php
declare(strict_types=1);
namespace App\Scheduler\Message;
final readonly class SendDeadlineReminder
{
public function __construct(
public int $deadlineId,
public string $reminderLevel, // 'J-7', 'J-3', 'J-1', 'J-0'
) {}
}<?php
// src/Scheduler/AppSchedule.php
declare(strict_types=1);
namespace App\Scheduler;
use App\Scheduler\Message\CheckUpcomingDeadlines;
use App\Scheduler\Message\GenerateMonthlyReports;
use App\Scheduler\Message\SyncIncrementalStock;
use Psr\Clock\ClockInterface;
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 readonly class AppSchedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
private LockFactory $lockFactory,
private ClockInterface $clock,
) {}
public function getSchedule(): Schedule
{
return (new Schedule())
->add(
RecurringMessage::every(
'1 hour',
new CheckUpcomingDeadlines($this->clock->now()),
),
RecurringMessage::every(
'2 minutes',
new SyncIncrementalStock(),
),
RecurringMessage::cron(
'0 2 1 * *',
new GenerateMonthlyReports($this->clock->now()->modify('first day of last month')),
),
)
->stateful($this->cache)
->lock($this->lockFactory->createLock('scheduler-default', ttl: 300));
}
}<?php
// src/Scheduler/Handler/CheckUpcomingDeadlinesHandler.php
declare(strict_types=1);
namespace App\Scheduler\Handler;
use App\Repository\DeadlineRepository;
use App\Scheduler\Message\CheckUpcomingDeadlines;
use App\Scheduler\Message\SendDeadlineReminder;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final readonly class CheckUpcomingDeadlinesHandler
{
public function __construct(
private DeadlineRepository $deadlines,
private MessageBusInterface $bus,
) {}
public function __invoke(CheckUpcomingDeadlines $msg): void
{
$now = $msg->at;
foreach ($this->deadlines->findUpcoming($now, daysAhead: 7) as $deadline) {
$daysUntil = (int) $now->diff($deadline->getDueAt())->days;
$level = match (true) {
$daysUntil === 7 && $deadline->isCritical() => 'J-7',
$daysUntil === 3 && $deadline->isCritical() => 'J-3',
$daysUntil === 1 => 'J-1',
$daysUntil === 0 => 'J-0',
default => null,
};
if ($level === null) {
continue;
}
$this->bus->dispatch(new SendDeadlineReminder(
deadlineId: $deadline->getId(),
reminderLevel: $level,
));
}
}
}<?php
// src/Scheduler/Handler/SendDeadlineReminderHandler.php
declare(strict_types=1);
namespace App\Scheduler\Handler;
use App\Repository\DeadlineRepository;
use App\Repository\ReminderLogRepository;
use App\Scheduler\Message\SendDeadlineReminder;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Notifier\Recipient\Recipient;
#[AsMessageHandler]
final readonly class SendDeadlineReminderHandler
{
public function __construct(
private DeadlineRepository $deadlines,
private ReminderLogRepository $log,
private NotifierInterface $notifier,
) {}
public function __invoke(SendDeadlineReminder $msg): void
{
// Idempotency : contrainte unique (deadline_id, level) en DB
if (!$this->log->tryRecord($msg->deadlineId, $msg->reminderLevel)) {
return;
}
$deadline = $this->deadlines->find($msg->deadlineId)
?? throw new \RuntimeException("Deadline {$msg->deadlineId} not found");
$assignee = $deadline->getAssignee();
$notification = (new Notification(
subject: sprintf('Échéance %s : %s', $msg->reminderLevel, $deadline->getTitle()),
channels: $msg->reminderLevel === 'J-0' ? ['sms', 'email'] : ['email'],
))
->content($this->buildContent($deadline, $msg->reminderLevel))
->importance(in_array($msg->reminderLevel, ['J-1', 'J-0'], true)
? Notification::IMPORTANCE_URGENT
: Notification::IMPORTANCE_HIGH);
$this->notifier->send(
$notification,
new Recipient($assignee->getEmail(), $assignee->getMobile() ?? ''),
);
}
private function buildContent($deadline, string $level): string
{
return sprintf(
"Échéance %s pour le dossier %s (client %s).\nDue le %s.\n%s",
$level,
$deadline->getCase()->getReference(),
$deadline->getCase()->getClientName(),
$deadline->getDueAt()->format('d/m/Y H:i'),
$deadline->getDescription(),
);
}
}# config/packages/messenger.yaml
framework:
messenger:
transports:
scheduler_default: 'in-memory://'
notifications:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy: { max_retries: 3, delay: 5000, multiplier: 2 }
failed: 'doctrine://default?queue_name=failed'
routing:
'App\Scheduler\Message\SendDeadlineReminder': notifications
'App\Scheduler\Message\SyncIncrementalStock': notifications
failure_transport: failedCREATE UNIQUE INDEX uniq_reminder_log
ON reminder_log (deadline_id, reminder_level);En prod, deux processus systemd : un messenger:consume scheduler_default (1 instance avec le lock distribué) et un messenger:consume notifications --limit=200 --time-limit=3600 (8 instances en parallèle). Le canary RecurringMessage::every('1 minute', new SchedulerHeartbeat()) écrit dans Redis et Prometheus alerte si la métrique disparaît plus de 3 minutes — signal d'un worker mort à investiguer.
🔁 Quand utiliser / éviter
Utiliser Scheduler quand :
- Votre stack utilise déjà Messenger (transport async, workers).
- Vous voulez versionner et tester vos crons comme du code.
- Vous avez besoin de logique conditionnelle (jours fériés, time zone par utilisateur, triggers dynamiques).
- Vous voulez observer (logs, traces, metrics) vos jobs récurrents avec la même tooling que vos messages.
Éviter Scheduler quand :
- Vous êtes sur PHP-FPM seul sans possibilité de tourner un worker permanent ⇒ crond système plus simple.
- Vous avez un seul cron tous les 6 mois ⇒ overhead de complexité non justifié.
- Vous êtes en Kubernetes et préférez la traçabilité d'un CronJob (1 Job = 1 Pod = 1 log isolable) plutôt qu'un worker long-running.
- Vous avez besoin de timing sub-seconde garanti ⇒ Scheduler boucle à ~1 Hz, pas en temps réel dur.
Migration depuis crond
Si vous migrez un cron système existant vers Scheduler, faites-le progressivement :
- Inventaire : lister tous les
crontab -let/etc/cron.d/*. Catégoriser par criticité. - Première vague : migrer les crons applicatifs non critiques (envoi de digests, calcul de stats). Tester en double exécution (crond + Scheduler) sur staging.
- Deuxième vague : migrer les crons applicatifs critiques (facturation mensuelle, sync). Mettre en place le monitoring "canari".
- Garder en crond système : les jobs non-PHP (logrotate, backup base, certificats), les jobs de maintenance host (cleanup tmp, rotation logs Symfony).
Erreurs à éviter pendant la migration :
- Laisser un cron actif des deux côtés ⇒ doublons.
- Migrer un cron qui utilise un PATH spécifique sans vérifier.
- Oublier d'ajuster le timezone (crond utilise celui du système ; Scheduler celui de PHP/Schedule).
Comparaison synthétique
| Critère | Symfony Scheduler | crond système | Kubernetes CronJob | BullMQ scheduler (Node) |
|---|---|---|---|---|
| Définition | Code PHP, versionnée | /etc/cron.d/*, déploiement séparé | YAML manifest | Code JS |
| Exécution | Dans un worker Messenger | Fork process (cold start) | Nouveau Pod par run | Worker Node permanent |
| Distribué | Oui (avec lock) | Non (1 host) | Natif (k8s) | Oui (Redis-based) |
| Retry | Via Messenger | Manuel | backoffLimit | Natif (queue retry) |
| Observabilité | Logs Messenger / events | Mail root @ host, syslog | Logs Pod k8s | Bull Board, Sentry |
| Cold start | 0 (worker chaud) | ~100–500 ms (fork PHP) | ~5–30 s (pull image, init) | 0 |
| Cas d'usage idéal | App Symfony moderne, jobs récurrents nombreux | Mainteneur unique, jobs simples | Workloads isolés, multi-tenant | App Node, queue-heavy |
Architecture de référence en production
┌──────────────────────────────┐
│ Schedule Provider (PHP) │
│ - cron expressions │
│ - triggers personnalisés │
│ - stateful (cache Redis) │
│ - lock (Redis) │
└──────────────────────────────┘
│
┌───────────────────┴───────────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ messenger:consume │ │ messenger:consume │
│ scheduler_default │ │ scheduler_default │
│ (pod 1) │ │ (pod 2) │
│ acquires lock │ │ waits, exits │
└─────────────────────┘ └─────────────────────┘
│
│ dispatch RecurringMessage payload
▼
┌─────────────────────────────────────────────────────┐
│ Transport `async` (RabbitMQ / Doctrine / Redis) │
└─────────────────────────────────────────────────────┘
│
┌──────────┴──────────────┬───────────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ worker 1 │ │ worker 2 │ ... │ worker N │
│ handler │ │ handler │ │ handler │
└──────────┘ └──────────┘ └──────────┘Cette architecture sépare clairement :
- Le tick scheduler (1 leader, peu de CPU).
- L'exécution effective (N workers, scalable horizontalement).
🧮 Comment un staff engineer raisonne sur le Scheduler
Au-delà de « quelle syntaxe cron », les arbitrages qui distinguent une implémentation senior :
| Question | Mauvais réflexe | Raisonnement staff |
|---|---|---|
| Où exécuter le handler ? | Tout en sync dans le worker scheduler | Le worker scheduler est un leader unique (à cause du lock) : tout handler lourd qui y tourne bloque tous les autres ticks. Router systématiquement vers async, garder le worker scheduler quasi vide. |
| Garantie de livraison ? | « le scheduler garantit l'exécution » | Le scheduler garantit le déclenchement (at-most-once par défaut, at-least-once avec stateful). La fiabilité métier vient de Messenger (retry, DLQ) + handlers idempotents. Le timing n'est jamais une garantie. |
| Que se passe-t-il pendant un downtime ? | Ne pas y penser | Sans stateful : les ticks ratés sont perdus (souhaitable pour HealthPing). Avec stateful : ils sont rejoués, potentiellement en rafale. processOnlyLastMissedRun() ne rejoue que le dernier — c'est presque toujours ce qu'on veut pour un digest. |
| Cluster ? | 1 lock et c'est réglé | Le lock empêche le double déclenchement, pas le double traitement d'un message déjà en file. La défense en profondeur = lock côté scheduler + idempotency côté handler. Le store du lock (Redis/Doctrine) devient un SPOF : prévoir un TTL et un fallback. |
| Granularité fine (sub-minute) ? | every('5 seconds') partout | La boucle tourne à ~1 Hz : OK pour 5–30 s, mais sous la seconde le composant n'est pas un ordonnanceur temps-réel. Pour du sub-seconde déterministe, sortir de la sphère cron (event-driven, timer dédié). |
| Schedule dynamique (base de données) ? | Recharger à chaque tick | getSchedule() est mémoïsé : un changement en base n'est pris qu'au redémarrage du worker. Stratégie : déploiement qui recycle le worker, ou signal SIGTERM propre, ou un message « reload » qui appelle messenger:stop-workers. |
Le modèle mental à retenir : le Scheduler est une source d'événements temporels, pas un moteur d'exécution. Il pousse des messages ; toute la robustesse vit dans Messenger et dans la conception des handlers. Quand quelque chose va mal en prod, la question n'est presque jamais « le cron a-t-il déclenché » mais « le message a-t-il été traité, retried, ou mis en DLQ ».
🏋️ Exercices
Exercice 1 — Le scheduler minimal qui rattrape (implémenter)
Objectif : créer un RecurringMessage::cron('*/2 * * * *', new Tick()) avec un handler qui logge now(), puis le rendre stateful et observer le rattrapage après avoir coupé le worker 5 minutes.
Indice/Solution : MockClock en test ; en local, messenger:consume scheduler_default, Ctrl-C, attendre, relancer. Sans stateful → 1 seul tick au redémarrage. Avec stateful($cache) → les ticks manqués rejouent. Ajouter ->processOnlyLastMissedRun(true) pour ne garder que le dernier.
Exercice 2 — Trigger métier « dernier jour ouvré du mois » (implémenter, niveau 2)
Objectif : déclencher une clôture comptable le dernier jour ouvré du mois à 23h, fériés FR exclus. Pas de cron standard ne l'exprime — il faut un TriggerInterface.
Indice/Solution : partir de $run, sauter au dernier jour du mois (modify('last day of this month')->setTime(23,0)), puis reculer tant que isHoliday() ou weekend. Gérer le cas où $run est déjà après cette date → passer au mois suivant. Tester avec MockClock sur un mois finissant un dimanche férié.
Exercice 3 — Anti-duplication en cluster (production-grade)
Objectif : lancer 3 workers scheduler simultanés et prouver qu'un seul message est produit par tick. Mesurer ce qui se passe quand le lock store (Redis) tombe.
Indice/Solution : ->lock($lockFactory->createLock('sched', ttl: 300)) avec un RedisStore partagé. Lancer 3 messenger:consume scheduler_default. Compter les messages en file (transport Doctrine, SELECT count(*)). Couper Redis : selon le LockFactory, soit blocage, soit LockAcquiringException → les 3 workers redeviennent actifs (split-brain). Conclusion à formuler : le lock store est un SPOF, l'idempotency handler reste obligatoire.
Exercice 4 — Fan-out shardé sans bloquer le leader (production-grade)
Objectif : un cron nocturne doit traiter 100 000 entités en < 30 min. Implémenter un message kickoff qui shard en N, dispatché vers async, avec progression observable.
Indice/Solution : KickoffHandler qui dispatch(new ProcessShard($i, $n)) pour range(0, n-1) ; routing ProcessShard → async ; N workers --limit. Exposer une gauge Prometheus shards_remaining. Vérifier que le worker scheduler n'exécute jamais le travail lourd (sinon les ticks suivants gèlent).
Exercice 5 — Break-then-fix : la rafale de rattrapage (casser puis réparer)
Objectif : avec every('30 seconds') + stateful sans processOnlyLastMissedRun, simuler 2h de downtime et observer ~240 messages produits d'un coup qui noient la file. Puis corriger.
Indice/Solution : reproduire avec un MockClock avancé de 2h. Le bug : stateful rejoue chaque créneau manqué. Fix 1 : processOnlyLastMissedRun(true). Fix 2 : retirer stateful pour les tâches haute fréquence où un point manqué est sans importance (health checks). Formuler la règle : stateful ⇔ « chaque occurrence compte » (facturation), jamais pour du monitoring.
Exercice 6 — Schedule piloté par la base avec hot-reload (architecte)
Objectif : permettre à un admin d'ajouter/modifier des tâches cron via une UI, et que le scheduler les prenne en compte sans redéploiement.
Indice/Solution : getSchedule() lit scheduled_tasks. Problème : mémoïsé, donc figé jusqu'au restart. Solutions à comparer : (a) dispatcher messenger:stop-workers après chaque écriture admin (simple, coupe brièvement) ; (b) faire du worker scheduler un process à --time-limit court (ex. 60 s) qui se recharge souvent ; (c) écouter un canal Redis pub/sub pour déclencher un stop ciblé. Discuter le trade-off réactivité vs. stabilité, et le fait que getSchedule() ne doit jamais faire d'I/O lente (timeout DB = scheduler mort).
🎤 En entretien
Q : Le Scheduler garantit-il qu'un job s'exécutera exactement une fois ? Non. Il garantit le déclenchement (at-most-once sans stateful, at-least-once avec). L'exactly-once métier s'obtient en combinant lock côté scheduler (pas de double déclenchement en cluster) et handlers idempotents côté Messenger (un message rejoué par retry/DLQ ne doit pas avoir d'effet de bord en double). Le scheduler seul ne suffit jamais.
Q : Pourquoi ne pas exécuter le handler directement dans le worker scheduler ? Parce que le worker scheduler est un leader unique (verrouillé en cluster) : un handler lent y bloque tous les autres ticks. On route donc les messages vers un transport async consommé par N workers scalables horizontalement, et on garde le tick scheduler quasi sans CPU. Séparation tick / exécution.
Q : stateful() vs sans — comment choisir ?stateful mémorise la dernière exécution et rejoue les créneaux manqués après un downtime. À activer quand chaque occurrence compte (facturation, digest). À éviter pour les tâches haute fréquence (health ping) où le rattrapage produit une rafale inutile. Toujours coupler avec processOnlyLastMissedRun(true) si on ne veut que la dernière occurrence ratée.
Q : Scheduler Symfony vs Kubernetes CronJob — quand préférer lequel ? Scheduler si l'app utilise déjà Messenger, si on veut versionner/tester les crons comme du code, partager retry/middleware/observabilité, et orchestrer beaucoup de jobs récurrents avec de la logique conditionnelle. CronJob k8s si on veut l'isolation totale (1 run = 1 pod = 1 log, cleanup process garanti, pas de fuite mémoire long-running) et qu'on accepte le cold start (pull image + boot). Souvent on combine : crond/CronJob pour la maintenance host non-PHP, Scheduler pour les jobs applicatifs.
🔗 Liens
- Doc officielle : https://symfony.com/doc/current/scheduler.html
- Composant Clock : https://symfony.com/doc/current/components/clock.html
- Article de Fabien Potencier sur l'intro du scheduler
dragonmantank/cron-expression(parser utilisé en interne)- Kubernetes CronJob doc : https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/
Récap final
Le Scheduler Symfony unifie timing récurrent et bus de messages sous une seule abstraction. Le bénéfice principal n'est pas la syntaxe cron — celle-ci est universelle — mais l'intégration native avec Messenger : vos crons héritent automatiquement du retry, des middlewares, du transport async, des outils de debug. Pour des applications Symfony modernes en cluster, c'est le standard de facto à partir de 6.4 LTS. Pour des cas simples ou des environnements sans worker, crond système ou k8s CronJob restent légitimes. La règle d'or : un worker chaud, un lock partagé, des handlers idempotents.