Comme beaucoup de monde j'ai utilisé les cron non pas par dogme ou par flemme, mais parce que mon site tournait sur un petit VPS. Il n'y avait donc pas de worker Messenger qui tournait en permanence, ni Redis. Symfony Scheduler sans ces deux choses là n'aurait été qu'un cron déguisé en PHP. Mêmes promesses, mêmes drames mais avec une couche d'abstraction inutile en plus.
Et puis j'ai fait le choix de VPS plusieurs crans au-dessus avec notamment deux instances Redis (une volatile, une non-volatile) et un worker Messenger sous Supervisor, transport async avec retry strategy. Le jour où ces trois briques sont arrivées, MainSchedule.php s'est écrit tout seul — et devops/cron/ est parti à la corbeille.
Ce billet n'est pas là pour dire que Scheduler est génial dans l'absolu — il ne l'est que si on a l'infra qui va avec, mais tout simplement pour décrire les trois briques à avoir sous la main avant #[AsSchedule] et les cinq jobs qui tournent aujourd'hui sur ce site.
Cron n'est pas le problème — la stack autour, si
On va vite : cron traîne dans nos machines depuis la fin des années 70. Il fait UNE chose, et il la fait très bien depuis bientôt cinquante ans : lancer une commande à une heure donnée, et c'est tout.
Tout le reste — l'idempotence, le retry, l'observabilité, la coordination si on a plus d'une instance, la visibilité sur ce qui a raté — n'est pas dans cron et ça ne l'a jamais été. En soit, ce n'est pas un défaut mais juste un périmètre.
Le souci, c'est qu'une app Symfony en prod a besoin de tout ça et qu'on a longtemps bricolé dans des scripts bin/console … enveloppés dans des flock, des fichiers de verrouillage maison, des 2>&1 | logger, des if [ -f /tmp/job.lock ]; then …. Moi en tout cas oui.
Symfony Scheduler ne remplace pas cron. Il se branche sur ce que cron ne fait pas : un transport asynchrone (Messenger), un état persistant (cache pool), un verrou (Lock), une convention pour décrire le « quand » dans du code typé.
Les trois briques à avoir SOUS la main avant #[AsSchedule]
Avant d'écrire la moindre ligne de Scheduler, vérifier ces trois choses. Sinon on ré-implémente cron en plus lent :
- Un worker qui consume le transport Scheduler en permanence. Supervisor, systemd, service Docker — peu importe. Mais il faut que
bin/console messenger:consume scheduler_<nom>soit toujours up. Symfony Scheduler a un transport interne (SchedulerTransport) qui génère lesRecurringMessageà la volée selon les fréquences déclarées ; sans worker pour le consommer, le tick d'horloge n'a personne en face de lui. - (Optionnel, mais c'est ce qui change tout) Un transport pour découpler le tick de l'exécution. Par défaut, le worker qui consume
scheduler_<nom>exécute aussi les handlers, in-process. Ça marche — la doc officielle le présente comme l'usage de base. Mais le jour où un handler prend 30 secondes à tourner, le tick suivant attend. Pour découpler, deux options : envelopper les messages dans unRedispatchMessage, ou simplement laisser le routage Messenger ré-orienter lesApp\Message\Scheduling\*vers un transport async (Redis, Doctrine, AMQP). C'est l'option que ce blog a retenue : convention de routageApp\Message\* → async, retry strategy 3 tentatives, multiplier 2. Le tick reste léger, l'exécution scale indépendamment. - Un pool de cache non-volatile pour le state du Scheduler. C'est la brique qu'on rate. On y reviendra plus bas — c'est l'autocritique du billet.
La première brique arrive avec n'importe quel worker Messenger correctement supervisé. La deuxième est un choix d'architecture qu'on prend quand on ne veut pas qu'un job lent fige tout. La troisième, elle, demande qu'on ait pensé son architecture cache/Redis avant. Trois ceintures à serrer en amont ; les bretelles, c'est-à-dire le lock global du Scheduler, on les met après.
MainSchedule.php — cinq jobs, cinq patterns
Voici la classe qui tourne aujourd'hui. Je l'ai abrégée pour la lisibilité, le fichier complet vit dans le repo.
namespace App\Scheduler;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('main')]
final readonly class MainSchedule implements ScheduleProviderInterface
{
public function __construct(
#[Target('cache.scheduler')]
private CacheInterface $cache,
private LockFactory $lockFactory,
) {}
public function getSchedule(): Schedule
{
return new Schedule()
->add(
// 1. Publication / dépublication des contenus planifiés.
RecurringMessage::every('5 minutes', new PublishScheduledContentMessage()),
// 2. Pré-calcul matrice de similarité TF-IDF.
RecurringMessage::cron('10 7 * * 1', new WarmSimilarityMessage()),
// 3. Warmup du DashboardSnapshot.
RecurringMessage::every('5 minutes', new WarmDashboardSnapshotMessage()),
// 4. Purge mensuelle des règles de redirection inactives.
RecurringMessage::cron('0 3 1 * *', new CleanupInactiveRedirectsMessage()),
// 5. Warmup du snapshot "À lire" — 3×/jour, timezone Paris pour gérer le DST.
RecurringMessage::cron('0 7 * * *', new WarmReadingListMessage(), 'Europe/Paris'),
RecurringMessage::cron('0 13 * * *', new WarmReadingListMessage(), 'Europe/Paris'),
RecurringMessage::cron('0 19 * * *', new WarmReadingListMessage(), 'Europe/Paris'),
)
->stateful($this->cache)
->lock($this->lockFactory->createLock('scheduler-main'))
;
}
}Chaque ligne raconte un pattern différent :
- Job 1 — publish/unpublish toutes les 5 minutes. Le contenu planifié (un billet daté du futur, par exemple) bascule en
PUBLISHEDquand sa fenêtre s'ouvre. Cinq minutes, c'est le délai maximal qu'un lecteur ressent. L'idempotence vit côté service : la transition de workflow est filtrée avant tentative — un job qui tourne deux fois sur la même fenêtre ne casse rien. - Job 2 — matrice de similarité TF-IDF, lundi 7h10 UTC. Calcul lourd, qu'on ne veut pas en heure de pointe.
cron('10 7 * * 1')sans timezone explicite ⇒ UTC, soit lundi 9h10 Paris l'été, 8h10 l'hiver. Acceptable pour un job hebdomadaire dont personne ne dépend en direct. - Job 3 — warmup du DashboardSnapshot, toutes les 5 minutes. L'admin atterrit sur une page qui agrège GSC + Umami + Grafana ; sans warm-up, ce serait 500 ms d'attente à chaque ouverture. Le snapshot est invalidé/reconstruit en arrière-plan — le rendu admin n'attend jamais une API externe.
- Job 4 — purge des redirects mensuelle, 1er du mois à 3h UTC. Opération destructive : on supprime des règles de redirection. Plusieurs ceintures (et plusieurs bretelles) côté handler : on ne supprime QUE les règles avec
hitCount = 0ETupdatedAt > 30j, cap de sécurité à 200 suppressions par run. Never break a 301, c'est de la valeur SEO acquise — un backlink qui pointe vers un vieux billet déplacé vaut plus qu'on ne le pense. - Job 5 — reading list, trois fois par jour, timezone
Europe/Paris. Trois cron différents, calés sur les pics d'audience tech (avant boulot, pause déjeuner, après boulot). Le troisième argumentEurope/Parisdit au Scheduler de calculer le prochain run dans cette zone — le serveur prod reste en UTC, mais le DST est géré automatiquement. Pas de drift de 1 h aux changements d'heure, contrairement à ce qu'on ferait avec uncronhost.
Fréquence basse volontairement : chaque run consomme un appel GSC et un appel Umami. Trois fois par jour, ça suffit largement — le top 10 d'articles à lire ne change pas toutes les cinq minutes.
Le mot de la fin
L'outil suit l'infra, pas l'inverse. Cron a tenu 50 ans parce qu'il faisait une chose dans une machine unique. Symfony Scheduler tient en prod parce qu'il fait une chose au-dessus d'une stack qu'on a déjà — un worker, un transport, un état persistant.
Si la stack n'est pas là, Scheduler n'apporte rien que cron n'apportait déjà. Mais une fois qu'elle est là, persister à écrire des flock /tmp/job.lock bin/console … dans un fichier texte versionné, c'est du folklore.