Les crontabs, c'est l'angle mort de la plupart des architectures PHP. Un /etc/cron.d/monapp que personne ne touche, qui n'apparaît jamais dans une PR, et que tu redécouvres en prod le jour où un job part à 3h du matin au lieu de 6h — parce que le TZ du serveur ne correspond pas au fuseau métier. Symfony 7.1, sorti officiellement le 29 mai 2024, stabilise le Scheduler component. La promesse est simple : des tâches récurrentes déclarées en PHP typé, versionnées dans ton dépôt Git, intégrées à la dependency injection, testables nativement avec MockClock, et visibles dans le WebProfiler. Ce tutoriel migre une vraie crontab de A à Z.
Les crontabs, ce que tu ne documentes jamais
La crontab n'est pas un artefact de la préhistoire — elle fait le job depuis quarante ans. Le problème, c'est tout ce qu'elle ne fait pas : elle ne connaît pas ton container DI, elle ne te dit pas si le job a planté, si son exécution a dépassé son SLA, ou s'il tourne en parallèle d'une instance précédente. Les équipes compensent avec des scripts bash approximatifs, des logs épars dans /var/log/monapp-daily.log qu'on ne consulte qu'en cas d'incident, des commentaires # NE PAS TOUCHER qui survivent à deux rotations d'équipe, et des ajustements UTC/fuseau local codés en dur dans le champ horaire. Quand Symfony a conçu le Scheduler component (stabilisé après plusieurs versions expérimentales), l'objectif était précis : rendre les tâches planifiées aussi lisibles, testables et observables que n'importe quel service Symfony, sans sortir du framework.
Installation et configuration du worker
Le Scheduler est distribué en tant que package indépendant. Il dépend de Messenger — si tu l'as déjà en production, l'ajout est trivial. Prérequis : Symfony 7.1+, PHP 8.2+ (8.3 recommandé), Messenger installé. En environnement Docker, exécute ça dans le container applicatif :
# Installation
docker compose exec app composer require symfony/scheduler
# Vérifier que Messenger est bien présent
docker compose exec app composer show symfony/messenger
# Lancer le worker qui consomme les messages planifiés
docker compose exec app php bin/console messenger:consume scheduler_default --time-limit=3600 -vvContrairement aux workers Messenger classiques qui consomment des messages poussés dans une queue, le worker Scheduler interroge le schedule à intervalles réguliers (toutes les secondes par défaut). En production, tu le gères via Supervisor ou systemd, relancé automatiquement en cas de crash. Il peut cohabiter avec tes workers async existants — ce sont simplement deux processus distincts.
Anatomie d'une tâche planifiée Symfony 7.1
Le Scheduler s'appuie sur trois briques : un provider de schedule qui déclare les tâches, des messages (des POPO Symfony Messenger) qui représentent le travail à effectuer, et des handlers qui exécutent ce travail. L'intégration avec Messenger n'est pas un détail : tes tâches planifiées bénéficient automatiquement de la gestion des erreurs, de la retry policy et des transports configurés dans messenger.yaml.
Le provider de schedule
Implémente ScheduleProviderInterface et décore la classe avec #[AsSchedule]. L'attribut enregistre automatiquement le provider dans le container DI. L'argument de l'attribut — ici 'default' — détermine le nom du transport : scheduler_default. Le provider retourne un objet Schedule qui agrège tes RecurringMessage. Note le ->timezone() : c'est lui qui règle définitivement le problème UTC/fuseau métier.
<?php
// src/Scheduler/MainSchedule.php
namespace App\Scheduler;
use App\Scheduler\Message\GenerateDailyReportMessage;
use App\Scheduler\Message\PruneExpiredTokensMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
#[AsSchedule('default')]
final class MainSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())
->timezone(new \DateTimeZone('Europe/Paris'))
->add(
// Lundi-vendredi à 06h00 heure de Paris — sans ambiguïté UTC
RecurringMessage::cron('0 6 * * 1-5', new GenerateDailyReportMessage()),
// Toutes les heures, à partir du démarrage du worker
RecurringMessage::every('1 hour', new PruneExpiredTokensMessage()),
);
}
}Le message et son handler
Les messages sont de simples value objects. Pas de logique, pas de dépendances. Utilise readonly pour garantir l'immutabilité — c'est du PHP 8.3 idiomatique. Le handler, lui, reçoit le message via l'autowiring Messenger. Injecter ClockInterface plutôt que d'appeler new \DateTimeImmutable() directement est la clé pour rendre le handler testable avec MockClock.
<?php
// src/Scheduler/Message/PruneExpiredTokensMessage.php
namespace App\Scheduler\Message;
final readonly class PruneExpiredTokensMessage {}<?php
// src/Scheduler/Handler/PruneExpiredTokensHandler.php
namespace App\Scheduler\Handler;
use App\Repository\TokenRepository;
use App\Scheduler\Message\PruneExpiredTokensMessage;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class PruneExpiredTokensHandler
{
public function __construct(
private readonly TokenRepository $tokenRepository,
private readonly ClockInterface $clock,
) {}
public function __invoke(PruneExpiredTokensMessage $message): void
{
$cutoff = $this->clock->now()->modify('-30 days');
$this->tokenRepository->deleteExpiredBefore($cutoff);
}
}Migrer une vraie crontab, étape par étape
Besoin d'un expert Symfony ?
Réserver un appel →Voici une crontab réelle, issue d'un projet e-commerce en production. Deux jobs, deux problèmes classiques : le fuseau horaire géré mentalement par le développeur (commentaire dans le fichier) et une commande appelée via un chemin absolu codé en dur qui cassera au prochain déménagement de serveur.
# /etc/cron.d/ecommerce
# ATTENTION : serveur en UTC, on veut 06h00 Paris (UTC+2 en été, UTC+1 en hiver)
# En hiver mettre 5 au lieu de 4 !!!
0 4 * * 1-5 www-data /var/www/html/bin/console app:report:daily >> /var/log/daily-report.log 2>&1
# Purge tokens expirés — toutes les heures
0 * * * * www-data /var/www/html/bin/console app:tokens:prune >> /var/log/prune-tokens.log 2>&1Le commentaire # En hiver mettre 5 au lieu de 4 !!! est un aveu d'échec architectural. Ce fichier n'est pas dans Git, il faut se connecter en SSH sur le serveur pour le modifier, et au premier changement d'heure oublié, le rapport part une heure trop tôt ou trop tard. La migration avec le Scheduler règle tout ça en un seul fichier PHP versionné. La configuration Messenger associée :
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
scheduler_default:
dsn: 'scheduler://default'
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 3
delay: 1000
multiplier: 2
failed:
dsn: 'doctrine://default?queue_name=failed'
routing:
App\Scheduler\Message\GenerateDailyReportMessage: async
App\Scheduler\Message\PruneExpiredTokensMessage: asyncAvec cette configuration, le worker scheduler_default génère les messages selon le schedule et les route vers le transport async pour une exécution asynchrone. En cas d'échec, Messenger retente jusqu'à 3 fois (délai exponentiel) avant d'envoyer le message dans la queue failed — que tu peux rejouer manuellement avec messenger:failed:retry. Cette retry policy était impossible à configurer proprement avec une crontab.
Tester sans manipuler l'horloge système — MockClock
C'est là que le Scheduler component brille vraiment. En injectant ClockInterface dans tes handlers plutôt qu'en appelant new \DateTimeImmutable() directement, tu peux substituer MockClock dans les tests. Plus besoin de tricher avec uopz_set_return, Carbon::setTestNow() ou des mocks fragiles qui cassent à la première mise à jour de dépendance. MockClock est fourni par symfony/clock — disponible depuis Symfony 6.2 et inclus par défaut depuis 7.x. Le test est totalement déterministe et reproductible sur n'importe quelle machine, quelle que soit la date réelle.
<?php
// tests/Scheduler/Handler/PruneExpiredTokensHandlerTest.php
namespace App\Tests\Scheduler\Handler;
use App\Repository\TokenRepository;
use App\Scheduler\Handler\PruneExpiredTokensHandler;
use App\Scheduler\Message\PruneExpiredTokensMessage;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
final class PruneExpiredTokensHandlerTest extends TestCase
{
private TokenRepository&MockObject $repository;
private PruneExpiredTokensHandler $handler;
protected function setUp(): void
{
$this->repository = $this->createMock(TokenRepository::class);
// On fixe l'horloge au 16 juillet 2024 à 08h00 — déterministe, sans side-effect
$this->handler = new PruneExpiredTokensHandler(
tokenRepository: $this->repository,
clock: new MockClock(new \DateTimeImmutable('2024-07-16 08:00:00 Europe/Paris')),
);
}
#[Test]
public function it_deletes_tokens_expired_more_than_30_days_ago(): void
{
// J - 30 jours = 16 juin 2024 à 08h00
$expectedCutoff = new \DateTimeImmutable('2024-06-16 08:00:00 Europe/Paris');
$this->repository
->expects(self::once())
->method('deleteExpiredBefore')
->with($expectedCutoff);
($this->handler)(new PruneExpiredTokensMessage());
}
#[Test]
public function it_does_not_throw_on_valid_invocation(): void
{
$this->repository
->expects(self::once())
->method('deleteExpiredBefore');
// Le handler tourne — on vérifie juste qu'il ne lève pas d'exception
($this->handler)(new PruneExpiredTokensMessage());
}
}Si demain tu changes la durée de rétention de 30 à 45 jours, le premier test devient rouge immédiatement. Pas besoin d'attendre un incident prod pour le découvrir. Tu peux aussi écrire un test d'intégration qui vérifie que le schedule déclare exactement les bons RecurringMessage — et le rejouer en CI sans aucun effet de bord sur le système de fichiers ou la base de données.
Observabilité : WebProfiler et commandes de diagnostic
Le Scheduler enrichit le WebProfiler d'un panneau dédié, visible en environnement dev. Tu y retrouves la liste des tâches planifiées, leur prochain déclenchement calculé selon le timezone configuré, et l'historique des messages générés pendant la session courante. En ligne de commande, deux commandes couvrent l'essentiel du diagnostic :
# Lister les schedules enregistrés et leurs prochains déclenchements
docker compose exec app php bin/console debug:scheduler
# Inspecter les messages en échec
docker compose exec app php bin/console messenger:failed:show
# Rejouer manuellement un message échoué
docker compose exec app php bin/console messenger:failed:retryEn production, l'observabilité repose sur les logs Messenger (configurables via Monolog) et sur la queue failed. Un job qui échoue silencieusement dans une crontab classique génère au mieux une entrée dans un fichier log ignoré. Avec le Scheduler, l'échec produit un message dans la queue failed, visible et rejouable, avec la stack trace complète. C'est un contrat d'observabilité que la crontab ne pourra jamais tenir.
Ce que cette migration apporte concrètement
- Versioning Git natif : les tâches planifiées font partie du code review, pas de la dette d'ops. Une PR pour modifier un schedule, une trace dans l'historique.
- Fuseau horaire déclaratif : un seul appel
->timezone(new \DateTimeZone('Europe/Paris'))élimine tous les commentaires UTC/heure d'hiver. - Retry policy intégrée : plus de jobs silencieux qui échouent sans laisser de trace. Messenger retente, puis stocke dans
failedpour rejeu manuel. - Testabilité native : MockClock rend chaque handler déterministe sans patch global ni dépendance à Carbon ou uopz.
- Observabilité out-of-the-box : WebProfiler, logs Monolog structurés et queue
failedsans aucune configuration supplémentaire. - Zéro dépendance infra : tu supprimes
/etc/cron.d/monappdéfinitivement. Le schedule vit dans ton container applicatif, comme le reste du code.
La migration d'une crontab vers le Scheduler component n'est pas un refactoring de confort. C'est une décision d'architecture qui déplace tes tâches planifiées du territoire ops — fichiers épars sur un serveur, ajustements manuels, logs non structurés — vers le territoire code : versionné, reviewé, testé, observable. Si tu as déjà Messenger en production, l'installation se résume à un composer require et quelques minutes de configuration. Le premier debug:scheduler en prod, avec tes jobs listés proprement et leurs prochains déclenchements calculés dans le bon timezone, vaut largement la migration.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.