Surveiller le scheduler Laravel : le cron unique qui masque tous les échecs silencieux
Dernière mise à jour : juillet 2026 · Par Yohann Kipfer, Cron-Ping
Le scheduler Laravel tourne sur une seule ligne de crontab — * * * * * php artisan schedule:run. Chaque tâche ->daily(), ->hourly() ou ->everyFiveMinutes() vit dans ce processus unique. Si cette ligne est retirée, que le conteneur est tué par l'OOM ou qu'un déploiement efface la crontab, plus rien ne tourne — et Laravel ne lève aucune erreur, puisque de son point de vue il n'a simplement jamais été appelé. Mettez un heartbeat dans le scheduler :
// routes/console.php (Laravel 11+)
Schedule::call(fn () => Http::get('https://cron-ping.com/p/TOKEN'))
->everyFiveMinutes()
->name('cron-ping-heartbeat');Le ping ne part que si le scheduler est vivant. Aucun ping dans le délai de grâce → vous recevez un mail ou un message Slack. C'est la seule chose que php artisan schedule:list ne dira jamais : il affiche ce qui devrait tourner, pas ce qui a tourné.
Pourquoi `schedule:run` est un point de défaillance unique — et silencieux
L'installation officielle Laravel, c'est une seule entrée de crontab sur le serveur, ajoutée une fois puis oubliée :
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1Cette ligne réveille Laravel chaque minute. Laravel lit votre planning, détermine quelles tâches sont dues cette minute, et les lance. Une centaine de tâches planifiées — sauvegardes, facturation, réchauffage de cache, e-mails de rapport — dépendent donc de ce *seul* processus appelé. Le scheduler est un répartiteur, pas un démon : il n'a aucune mémoire des minutes ratées et aucune reprise.
Regardez la redirection : >> /dev/null 2>&1. Elle figure dans la doc officielle de Laravel, et elle jette toute la sortie. C'est volontaire — sinon cron vous envoie un mail chaque minute — mais ça veut aussi dire que quand schedule:run se met à échouer (une erreur fatale dans un service provider, un .env cassé, une base indisponible au boot), l'erreur part dans /dev/null. La liste des tâches reste parfaite. Rien ne tourne.
schedule:work tué par l'OOM sans redémarrage ; une fatale PHP avant même que Laravel démarre. Dans tous les cas, schedule:list affiche encore vos tâches, et chacune est morte.Étape 1 — un heartbeat global dans le scheduler
Le signal le plus fiable est un ping émis par le scheduler lui-même, pour qu'il n'arrive que si le répartiteur tourne réellement. Sur Laravel 11 et 12, le planning vit dans routes/console.php :
// routes/console.php (Laravel 11 / 12)
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schedule;
Schedule::call(fn () => Http::get('https://cron-ping.com/p/TOKEN'))
->everyFiveMinutes()
->name('cron-ping-heartbeat')
->withoutOverlapping();Sur Laravel 10 et avant, la même chose va dans la méthode schedule() de app/Console/Kernel.php avec $schedule->call(...). Créez le check avec une *période de 5 minutes* et une grâce courte (2 minutes couvrent une file lente). Deux pings consécutifs manqués = le scheduler est à terre, pas une tâche, toutes.
Pourquoi un call() et pas un curl autour de schedule:run dans la crontab ? Parce qu'un curl au niveau crontab part même quand Laravel plante au boot — il rapporterait vert pendant que toutes les tâches sont mortes. Le ping doit venir d'après le démarrage réussi de Laravel, quand le répartiteur boucle vraiment.
Étape 2 — surveillance par tâche avec les hooks natifs
Le heartbeat prouve que le scheduler tourne. Il ne prouve pas que votre sauvegarde de nuit, elle, a réussi. Le scheduler Laravel embarque des *hooks de ping URL* faits exactement pour ça — aucun package, ils utilisent Guzzle dont Laravel dépend déjà :
Schedule::command('backup:run')
->dailyAt('02:30')
->withoutOverlapping()
->pingOnSuccess('https://cron-ping.com/p/BACKUP_TOKEN')
->pingOnFailure('https://cron-ping.com/p/BACKUP_TOKEN/fail');La liste complète des hooks, directement issue de Illuminate\Console\Scheduling :
| Hook | Se déclenche | Pour |
|---|---|---|
->pingBefore($url) | juste avant le début de la tâche | un ping /start pour mesurer la durée |
->thenPing($url) | après la tâche (quel que soit le résultat) | un simple heartbeat de succès |
->pingOnSuccess($url) | seulement si la tâche sort en 0 | confirmer que le job a vraiment marché |
->pingOnFailure($url) | seulement si la tâche sort non-zéro | taper l'endpoint /fail |
->pingBeforeIf($cond,$url) | avant, si la condition est vraie | ne pinguer que sur la prod |
->before() / ->after() | exécuter une closure autour de la tâche | log / métriques maison |
->onSuccess() / ->onFailure() | closure selon le résultat | notifier un canal précis |
Associez-les aux trois endpoints Cron-Ping : ->pingBefore() sur /p/TOKEN/start (démarre le chrono), ->pingOnSuccess() sur /p/TOKEN (l'arrête, enregistre l'exécution), ->pingOnFailure() sur /p/TOKEN/fail (alerte même si le scheduler, lui, va bien). Une sauvegarde qui tourne mais échoue à mi-chemin est enfin détectée — le heartbeat seul n'aurait rien vu.
Étape 3 — attraper un scheduler mort au dernier déploiement
La façon la plus fréquente de tuer un scheduler Laravel, c'est un déploiement, et chaque hébergement échoue différemment :
- *Forge* provisionne le cron
schedule:runpour vous quand vous activez le scheduler sur un site. Si vous migrez le site, restaurez un serveur ou que quelqu'un le désactive, la ligne disparaît et plus aucune tâche ne tourne — Forge n'affiche aucun avertissement. - *Envoyer / déploiement zéro-downtime bascule le lien symbolique `current`. Un `schedule:run` en cours contre l'ancien* chemin de release peut échouer, et un verrou
withoutOverlappingécrit sous l'ancien chemin peut bloquer l'exécution suivante. - *Docker / Kubernetes* font généralement tourner un conteneur séparé avec
php artisan schedule:work(la boucle qui appelleschedule:runchaque minute). Si ce conteneur est tué par l'OOM et que la restart policy est mauvaise, l'appli web reste debout et verte pendant que le scheduler a simplement disparu.
Un heartbeat avec une grâce serrée transforme ces trois cas en e-mail dans les minutes qui suivent le déploiement, au lieu d'une découverte plusieurs jours après quand quelqu'un demande pourquoi le rapport du lundi n'est jamais arrivé.
Les pièges : `withoutOverlapping`, tâches sous la minute, et la file
`withoutOverlapping()` peut se verrouiller lui-même
withoutOverlapping() prend un verrou pour qu'une tâche longue ne s'empile pas sur elle-même. Mais si le processus est kill -9 ou que le conteneur meurt en pleine exécution, le verrou n'est pas relâché. Laravel l'expire au bout de *24 heures* par défaut — une tâche peut donc sauter en silence pendant une journée entière. Passez une expiration explicite (->withoutOverlapping(10) = 10 minutes) pour qu'un verrou coincé se répare seul, et laissez le ping /fail vous dire que c'est arrivé.
`->everyFiveMinutes()` a quand même besoin du cron à la minute
Les helpers fréquents (everyMinute, everyFiveMinutes, everyThirtySeconds) ne sont pas magiques — ils sont toujours évalués par le schedule:run d'une fois par minute. everyThirtySeconds() ne marche que parce que schedule:run dort et re-vérifie dans la minute. Si le cron maître ne se déclenche pas, aucun de ces helpers ne tourne, quelle que soit la fréquence annoncée.
`queue:work` est un autre processus — ne pas confondre
Le scheduler répartit les tâches planifiées. Les jobs en file (ShouldQueue, dispatch()) sont gérés par un worker *séparé et permanent (`queue:work` / `queue:listen`, sous Supervisor ou Horizon). Un scheduler mort et un worker de file mort sont deux pannes indépendantes, avec deux symptômes indépendants. Si votre tâche `->daily()` dispatch elle-même un job en file, il vous faut les deux* en vie — surveillez le scheduler avec un heartbeat ici, et le worker de file séparément.
FAQ
Comment savoir si le scheduler Laravel tourne vraiment ?
php artisan schedule:list n'affiche que ce qui est défini, pas ce qui a tourné. Le vrai test : ajouter un Schedule::call() qui pingue une URL de surveillance toutes les quelques minutes. Si le ping cesse d'arriver, le scheduler est mort. Vérifier crontab -l pour la ligne schedule:run et systemctl status cron couvre le côté OS ; le heartbeat prouve que Laravel a bien démarré et bouclé.
Où mettre le planning en Laravel 11 et 12 ?
Dans routes/console.php, avec la façade Schedule — Schedule::command('...')->daily(). L'ancien app/Console/Kernel.php avec sa méthode schedule() marche encore si vous l'avez gardé, et c'est la seule option sur Laravel 10 et avant. Les deux pilotent le même schedule:run.
Quelle différence entre schedule:run et schedule:work ?
schedule:run évalue le planning une fois puis sort — c'est la commande que la crontab appelle chaque minute. schedule:work est une boucle au premier plan qui appelle schedule:run toute seule chaque minute, sans crontab. C'est ce qu'on lance dans un conteneur Docker. Dans les deux cas, si le processus n'est pas vivant, rien n'est réparti.
Les hooks de ping (pingOnSuccess) demandent un package ou de la config ?
Non. pingBefore, thenPing, pingOnSuccess et pingOnFailure sont intégrés au scheduler et utilisent Guzzle, déjà requis par Laravel. Vous passez une URL ; Laravel fait un GET quand le hook se déclenche. Pas de file, pas de package, aucune config de tâche planifiée.
Mon scheduler marche en local mais pas en prod après déploiement — pourquoi ?
Sur Forge, vérifiez que le toggle Scheduler du site a bien re-ajouté le cron ; sur Docker, que le conteneur schedule:work a redémarré et n'est pas tué par l'OOM ; sur tout hébergement, lancez php artisan schedule:run à la main et lisez la sortie — la crontab la cache dans /dev/null. Un check heartbeat avec une période de 5 minutes vous aurait envoyé un mail à l'instant où le déploiement l'a cassé.
Sachez en 5 minutes quand votre scheduler meurt
Ajoutez un heartbeat Schedule::call() et des hooks ->pingOnFailure() par tâche. Si schedule:run s'arrête — crontab effacée, conteneur tué par l'OOM, déploiement cassé — Cron-Ping vous alerte par e-mail ou Slack, au lieu de le découvrir quand le rapport du lundi n'est jamais parti.
Plan gratuit : 10 checks, alertes email, historique 7 jours. Sans carte. Hébergé en UE.