Cet article s’adresse aux équipes et aux dirigeants d’une application Laravel dont les tâches planifiées ont cessé de tourner : relances clients, export comptable de la nuit, synchronisation de stock. Cela arrive souvent après un déploiement ou un changement d’hébergement, et la commande php artisan schedule:list affiche pourtant des tâches et des horaires corrects. Vous verrez comment le scheduler Laravel déclenche réellement une tâche, les trois endroits où la chaîne casse quand les tâches ne s’exécutent pas (le cron du serveur, le fuseau horaire, le verrou withoutOverlapping), et comment obtenir la preuve qu’une tâche a bien tourné.
Comment le scheduler Laravel déclenche une tâche
Une tâche planifiée passe par trois étapes, et chacune dépend d’un endroit différent. D’abord, elle est définie dans le code : dans routes/console.php depuis Laravel 11, dans app/Console/Kernel.php sur les versions plus anciennes. Ensuite, quelque chose doit appeler la commande php artisan schedule:run. Enfin, au moment de cet appel, Laravel décide quelles tâches sont dues et les lance.
Le point qui surprend le plus : Laravel ne tourne pas en permanence pour surveiller l’horloge. Selon la documentation officielle, schedule:run évalue toutes les tâches en fonction de l’heure courante du serveur, lance celles qui doivent partir à cette minute, puis s’arrête. Il faut donc qu’une entrée cron système l’appelle chaque minute. Sans cet appel, aucune tâche ne part, même si le code est parfait.
Ce que schedule:list montre, et ce qu’il ne peut pas montrer
schedule:list lit les définitions dans le code et affiche, pour chaque tâche, sa fréquence et sa prochaine échéance. C’est utile pour relire la planification. En revanche, la commande ne lit pas la crontab du serveur, ne sait pas si un verrou est posé et n’indique pas si la dernière exécution a réussi. Elle peut donc afficher une liste impeccable sur un serveur où rien ne tourne.
Le deuxième réflexe, lancer la commande métier à la main (par exemple php artisan app:send-reminders), prouve que la logique fonctionne. Il ne dit pas si quelque chose l’appelle à l’heure prévue. Les journaux applicatifs restent souvent calmes, pour une raison simple : une commande qui n’a jamais été appelée n’écrit rien. L’équipe risque alors de chercher du côté du serveur d’e-mails ou de l’API partenaire, alors que la tâche n’est jamais partie.
L’environnement de recette peut aussi tromper. Il partage le même code, mais un développeur y lance parfois schedule:run à la main, ou schedule:work, la commande prévue pour le poste de développement. Les tâches y tournent, ce qui ne dit rien de la production.
Le déclenchement : un cron serveur doit appeler schedule:run
La documentation donne la ligne à ajouter dans la crontab : * * * * * cd /chemin-du-projet && php artisan schedule:run >> /dev/null 2>&1. Après une migration d’hébergement ou une réinstallation de serveur, cette ligne est facile à oublier, parce qu’elle ne fait pas partie du dépôt. Elle peut aussi exister mais pointer vers un autre dossier, une autre version de PHP, ou tourner sous un utilisateur qui n’a pas les droits d’écriture sur storage/.
Pour vérifier, lancez crontab -l sous l’utilisateur qui exécute l’application (souvent l’utilisateur de déploiement, pas root), puis exécutez la même commande que le cron, avec le même chemin et le même binaire PHP, et lisez la sortie. Le message « No scheduled commands are ready to run » est normal si aucune tâche n’est due à cette minute précise : il ne signale pas une panne.
L’heure : dans quel fuseau Laravel lit dailyAt
Une tâche écrite dailyAt('07:00') part à 7 h, mais 7 h dans quel fuseau ? Par défaut, celui de l’application, défini dans config/app.php, que le squelette Laravel règle sur UTC. Une tâche prévue « à 7 h » part alors à 9 h, heure de Paris, en été, et à 8 h en hiver. Elle tourne, mais elle peut tomber après le créneau attendu par le métier, ou pendant une maintenance du serveur d’e-mails.
La documentation propose deux réglages : la méthode timezone('Europe/Paris') sur une tâche, ou l’option schedule_timezone dans config/app.php pour toutes les tâches. Elle précise aussi qu’au changement d’heure, une tâche planifiée dans un fuseau à heure d’été peut s’exécuter deux fois ou pas du tout, et recommande d’éviter ces réglages quand c’est possible. L’autre option consiste donc à garder UTC et à écrire la correspondance (« 7 h Paris = 5 h UTC en été ») dans la documentation d’exploitation.
Le verrou withoutOverlapping : utile, mais il peut rester posé
On ajoute withoutOverlapping() pour qu’un export lourd ne démarre pas tant que le précédent n’est pas fini. C’est un bon réflexe (voir quand deux jobs écrivent le même export). Le verrou est stocké dans le cache de l’application. Si une exécution meurt sans le libérer, à cause d’un plantage, d’un redémarrage du serveur ou d’un processus tué, les appels suivants trouvent le verrou et ne lancent pas la tâche.
Ce verrou expire au bout de 24 heures par défaut, selon la documentation. Pendant ce temps, une tâche quotidienne peut sauter une journée, et une tâche lancée toutes les quinze minutes ne tourne plus du tout. schedule:list continue de l’afficher normalement. Pour lever un verrou bloqué, la documentation prévoit la commande php artisan schedule:clear-cache. Pour la suite, on peut passer une durée d’expiration adaptée, par exemple withoutOverlapping(30) pour une tâche qui ne dure jamais plus de quelques minutes.
Sur mon propre site : comment c’est réglé, et ce que j’ai corrigé en relisant
Le site sur lequel vous lisez cet article tourne en Laravel 13 et confie quatre tâches au scheduler, toutes déclarées avec withoutOverlapping() : les alertes de dépassement et de renouvellement des contrats de maintenance de mes clients, chaque jour à 8 h ; les PDF mensuels de ces contrats, le 1er du mois à 8 h 05 ; l’envoi de la newsletter pour les articles publiés, chaque heure ; et le traitement de la file d’attente, chaque minute.
Pour le fuseau, l’application est réglée sur Europe/Paris dans config/app.php, et non sur UTC. Les heures écrites dans le code sont donc des heures de Paris. Le risque signalé par la documentation au changement d’heure concerne les heures qui sautent ou se répètent, entre 2 h et 3 h du matin en France : les tâches métier, à 8 h et 8 h 05, sont en dehors de cette plage.
La file d’attente, en revanche, m’a fait corriger un réglage. Elle est traitée par la commande queue:work --stop-when-empty --max-time=50, lancée chaque minute avec withoutOverlapping() et runInBackground(), sans durée de verrou. Le verrou durait donc 24 heures par défaut. Si ce processus est tué en cours de route, par un redémarrage du serveur ou un manque de mémoire, le verrou peut rester posé : les appels suivants considèrent la tâche comme déjà en cours, et plus aucun job de la file ne tourne pendant jusqu’à 24 heures. C’est un risque que j’ai repéré en relisant ma configuration pour écrire cet article. J’ai ramené le verrou à 10 minutes, avec withoutOverlapping(10) : c’est plus long qu’un passage normal de la file, et un processus tué ne bloque plus les jobs que quelques minutes.
Prouver qu’une tâche planifiée a vraiment tourné
Ces trois causes ont un point commun : elles ne produisent aucune erreur. La parade consiste à surveiller le résultat attendu plutôt que l’absence d’erreur. Chaque commande métier peut écrire une trace horodatée au début et à la fin de son traitement, dans un journal ou une table. Laravel sait aussi appeler une URL avant et après une tâche, avec pingBefore et thenPing, ou seulement en cas de succès ou d’échec avec pingOnSuccess et pingOnFailure. Un service de surveillance externe alerte alors quand le signal attendu n’arrive pas.
Sur une application en maintenance, je ne considère pas le scheduler comme vérifié sans trois éléments : la ligne cron réelle de chaque environnement, une trace de succès d’au moins une tâche métier dans les dernières 24 à 48 heures, et la liste des tâches sous withoutOverlapping avec leur durée maximale attendue. Le fuseau retenu et la procédure pour lever un verrou vont dans la documentation d’exploitation. Le même principe vaut pour les files d’attente, traité dans fiabiliser les tâches asynchrones, et c’est l’un des points de la liste des erreurs que je corrige en maintenance Laravel.
Ce qu’il faut retenir
- Laravel ne lance aucune tâche planifiée sans un cron serveur qui appelle
schedule:runchaque minute. schedule:listmontre ce qui est défini dans le code, pas ce qui s’exécute sur le serveur.- Les heures sont lues dans le fuseau de l’application, UTC dans le squelette Laravel, sauf réglage explicite.
- Un verrou
withoutOverlappingorphelin bloque la tâche jusqu’à 24 heures par défaut ;schedule:clear-cachele lève. - Surveillez la trace de succès de chaque tâche métier plutôt que l’absence d’erreur.
Si vos relances, exports ou synchronisations se sont arrêtés alors que le scheduler a l’air sain, c’est le type de contrôle que je mets en place sur la page maintenance.
Commentaires 0
Soyez le premier à commenter cet article.
Laisser un commentaire