Symfony Messenger permet de déplacer les tâches lentes ou fragiles hors du cycle HTTP : envoyer un e-mail, générer un export, appeler une API tierce ou recalculer un document. Le contrôleur répond alors vite, tandis qu’un worker traite le travail en arrière-plan. Utilisez le mode synchrone pour une action dont le résultat est indispensable immédiatement ; basculez vers l’asynchrone dès qu’une tâche peut durer, échouer temporairement ou être rejouée. La clé n’est pas seulement de « mettre une queue » : il faut rendre chaque handler idempotent, observable et correctement supervisé.
Synchrone ou asynchrone : choisir selon le besoin métier
Avec Symfony Messenger, un message est un objet PHP représentant une intention : « envoyer cet e-mail », « produire ce rapport », « synchroniser ce client ». Un bus le transmet ensuite à un ou plusieurs handlers.
En mode synchrone, le handler s’exécute pendant la requête HTTP. C’est approprié lorsque l’utilisateur attend réellement le résultat et que l’opération est courte. Par exemple : vérifier une règle métier, recalculer un montant de panier ou enregistrer une préférence utilisateur.
final readonly class RecalculateCartTotal
{
public function __construct(public int $cartId)
{
}
}
#[AsMessageHandler]
final class RecalculateCartTotalHandler
{
public function __construct(private CartService $cartService)
{
}
public function __invoke(RecalculateCartTotal $message): void
{
$this->cartService->recalculate($message->cartId);
}
}
Le dispatch reste identique, quel que soit le transport :
$this->messageBus->dispatch(new RecalculateCartTotal($cart->getId()));
En asynchrone, Messenger sérialise le message, le place dans un transport, puis un worker le consomme. L’utilisateur reçoit souvent une réponse 202 Accepted, ou une redirection normale, sans attendre la fin de la tâche.
Voici une règle de décision utile :
| Situation | Mode conseillé | Pourquoi |
|---|---|---|
| Validation métier rapide | Synchrone | Le résultat conditionne immédiatement la réponse |
| Envoi d’e-mail transactionnel | Asynchrone | Le SMTP ou l’API peut être lent ou indisponible |
| Génération d’un CSV de plusieurs milliers de lignes | Asynchrone | Évite les timeouts PHP et HTTP |
| Appel à un ERP ou CRM distant | Asynchrone | Les erreurs réseau doivent pouvoir être rejouées |
| Mise à jour d’un cache local | Synchrone ou async | Dépend du caractère critique de la fraîcheur |
| Paiement ou réservation | Souvent synchrone, avec événements async | La confirmation doit être maîtrisée immédiatement |
L’asynchrone ne rend pas une opération « plus fiable » par magie. Il change le modèle : votre application doit accepter qu’un message puisse être traité plus tard, plusieurs fois, ou dans un ordre différent. Cette discipline rejoint les pratiques décrites dans notre article sur l’avenir du développeur web face à l’intelligence artificielle : déléguer l’exécution ne dispense jamais de comprendre les conséquences métier.
La documentation officielle de Symfony distingue clairement le bus, les transports et les workers. Commencez par lire la section Messenger de la documentation Symfony avant de choisir une infrastructure : le composant peut fonctionner avec Doctrine, Redis, AMQP/RabbitMQ, SQS et d’autres adaptateurs.
Installer Messenger et router les messages vers un transport
Installez Messenger et, selon votre besoin, le bridge du transport choisi :
composer require symfony/messenger
Pour une file basée sur Doctrine :
composer require symfony/doctrine-messenger
Pour Redis :
composer require symfony/redis-messenger
Pour RabbitMQ via AMQP :
composer require symfony/amqp-messenger
Une configuration minimale avec deux files, l’une dédiée aux e-mails et l’autre aux travaux lourds, pourrait ressembler à ceci :
# config/packages/messenger.yaml
framework:
messenger:
failure_transport: failed
transports:
async_email:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: emails
async_reports:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: reports
failed: '%env(MESSENGER_FAILED_TRANSPORT_DSN)%'
routing:
'App\Message\SendWelcomeEmail': async_email
'App\Message\GenerateMonthlyReport': async_reports
Le routage est central : sans lui, Messenger peut traiter le message immédiatement via le transport sync://. Cette valeur est pratique en développement ou pour certains messages métier, mais elle ne remplace pas un worker.
Une variable d’environnement pour Doctrine peut être :
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=messenger
Pour Redis, la DSN dépend de votre configuration, mais reste généralement de la forme :
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
Pour RabbitMQ, elle utilise une DSN AMQP :
MESSENGER_TRANSPORT_DSN=amqp://user:password@rabbitmq:5672/%2f/messages
Ne partagez pas nécessairement le même transport pour tous les travaux. Séparer les flux évite qu’une vague de génération de PDF bloque les e-mails de confirmation de commande. Vous pouvez également faire tourner davantage de workers sur la file la plus prioritaire.
Pour aller plus loin sur l’architecture Symfony et les composants qui composent une application maintenable, consultez ce guide pour apprendre Symfony en 2026. Messenger devient beaucoup plus simple lorsqu’il est intégré à des cas d’usage clairs, pas ajouté comme une couche abstraite sans frontière métier.
Choisir Doctrine, Redis ou RabbitMQ selon le contexte
Le choix du transport dépend de votre infrastructure, du volume, de la tolérance aux pannes et des garanties opérationnelles attendues. Il ne faut pas choisir RabbitMQ par réflexe si une table Doctrine répond correctement à votre charge réelle.
| Transport | Bon contexte | Points forts | Vigilances |
|---|---|---|---|
| Doctrine | Projet Symfony existant, volume modéré, démarrage rapide | Pas de service supplémentaire, inspection SQL simple | Charge la base, polling, nettoyage nécessaire |
| Redis | Infrastructure Redis déjà présente, traitements rapides et nombreux | Rapide, simple à exploiter, bon débit | Persistance et configuration Redis à vérifier |
| RabbitMQ | Plusieurs consommateurs, routage avancé, charge soutenue | Files robustes, acknowledgements, dead-lettering | Exploitation plus complexe, monitoring indispensable |
Doctrine est souvent le meilleur premier choix pour une application métier classique. La file est stockée en base, dans une table gérée par le transport. Elle est facile à consulter et à sauvegarder avec le reste de l’application. En revanche, un grand nombre de workers qui interrogent très fréquemment la base peut générer une pression inutile sur PostgreSQL ou MySQL.
Redis convient bien si vous utilisez déjà Redis pour le cache, les sessions ou les verrous. Sa faible latence est utile pour des tâches courtes et fréquentes. Mais ne considérez pas Redis comme durable par défaut : la stratégie de persistance, la réplication et le comportement lors d’un redémarrage doivent être connus de l’équipe.
RabbitMQ est adapté lorsque la messagerie devient un sujet d’architecture : plusieurs applications publient et consomment des événements, des priorités ou routes complexes sont nécessaires, et la file doit être administrée comme une brique autonome. RabbitMQ propose des mécanismes solides, mais vous devez surveiller les connexions, les files, les messages non acquittés et la capacité disque.
Quelques critères pratiques :
- choisissez Doctrine si vous voulez livrer rapidement avec peu d’infrastructure ;
- choisissez Redis si votre plateforme l’exploite déjà correctement et que vos messages restent simples ;
- choisissez RabbitMQ quand les besoins de routage, de résilience et de volumétrie justifient son coût opérationnel ;
- isolez les tâches longues dans une file dédiée, quelle que soit la technologie ;
- testez le comportement après redémarrage du broker, pas uniquement le cas nominal.
Ce raisonnement ressemble à celui du choix de framework : Laravel versus Symfony en 2026 n’a pas une réponse universelle, et les transports Messenger non plus. L’environnement existant compte autant que les fonctionnalités théoriques.
Écrire des messages et handlers robustes
Un message doit être petit, explicite et sérialisable. Préférez les identifiants aux entités Doctrine complètes. Passer une entité dans un message crée des problèmes de sérialisation, d’état périmé et de couplage avec l’ORM.
Pour un e-mail de bienvenue :
final readonly class SendWelcomeEmail
{
public function __construct(
public int $userId,
public string $requestId,
) {
}
}
Le handler recharge les données au moment du traitement :
#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
public function __construct(
private UserRepository $users,
private MailerInterface $mailer,
) {
}
public function __invoke(SendWelcomeEmail $message): void
{
$user = $this->users->find($message->userId);
if (!$user instanceof User) {
return;
}
if ($user->hasReceivedWelcomeEmail()) {
return;
}
$email = (new TemplatedEmail())
->to($user->getEmail())
->subject('Bienvenue')
->htmlTemplate('emails/welcome.html.twig')
->context(['user' => $user]);
$this->mailer->send($email);
$user->markWelcomeEmailAsSent();
}
}
Le test hasReceivedWelcomeEmail() est une forme d’idempotence. Un transport peut délivrer à nouveau un message après un incident : la bonne stratégie n’est pas de supposer « exactement une fois », mais de rendre le traitement sûr en cas de répétition.
Pour un rapport mensuel, évitez de transporter toutes les lignes de données :
final readonly class GenerateMonthlyReport
{
public function __construct(
public int $organizationId,
public string $month,
) {
}
}
Le handler peut créer un fichier, le stocker, puis enregistrer son emplacement :
#[AsMessageHandler]
final class GenerateMonthlyReportHandler
{
public function __construct(private ReportGenerator $generator)
{
}
public function __invoke(GenerateMonthlyReport $message): void
{
$this->generator->generate(
organizationId: $message->organizationId,
month: new \DateTimeImmutable($message->month),
);
}
}
Pour une synchronisation vers un service tiers, stockez aussi une clé métier ou une version. Cela aide à éviter d’envoyer une mise à jour ancienne après une mise à jour récente :
final readonly class SyncCustomerToCrm
{
public function __construct(
public int $customerId,
public \DateTimeImmutable $changedAt,
) {
}
}
Les pièges fréquents sont les suivants :
- envoyer une entité Doctrine au lieu de son identifiant ;
- mettre un secret ou un mot de passe dans le payload ;
- dépendre de l’état HTTP, de la session ou de l’utilisateur connecté dans un handler ;
- supposer que les messages seront traités dans leur ordre d’émission ;
- appeler un service externe sans timeout ni stratégie de reprise ;
- faire une transaction SQL et publier un message sans traiter le risque d’incohérence.
Sur ce dernier point, envisagez le pattern outbox pour les flux critiques : enregistrez dans la même transaction métier un événement à publier, puis faites-le envoyer par un processus dédié. Cela réduit le risque d’avoir une commande validée en base mais aucun message publié après un crash.
Configurer retries, échecs et file de secours
Les erreurs temporaires sont normales : une API distante répond en erreur, un serveur SMTP ralentit, une base est en maintenance. Messenger peut rejouer le message selon une stratégie de retry.
framework:
messenger:
transports:
async_sync:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
retry_strategy:
max_retries: 5
delay: 1000
multiplier: 2
max_delay: 60000
failed: '%env(MESSENGER_FAILED_TRANSPORT_DSN)%'
routing:
'App\Message\SyncCustomerToCrm': async_sync
Cette configuration augmente progressivement l’attente entre les tentatives. Elle évite de frapper une API indisponible plusieurs fois par seconde. Ajoutez du jitter si votre infrastructure le permet, afin que des centaines de messages ne repartent pas simultanément après une panne.
Dans le handler, différenciez les erreurs définitives des erreurs transitoires :
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
if ($response->getStatusCode() === 400) {
throw new UnrecoverableMessageHandlingException(
'La requête CRM est invalide.'
);
}
Une erreur 400 provient généralement de vos données ou de votre mapping : la rejouer cinq fois ne la corrigera pas. À l’inverse, une erreur réseau, un 429 ou un 503 mérite souvent une nouvelle tentative.
Les commandes indispensables sont :
php bin/console messenger:failed:show
php bin/console messenger:failed:retry
php bin/console messenger:failed:remove
Ne videz pas une file d’échec sans diagnostic. Un message en erreur est souvent un signal produit : changement de contrat d’API, champ obligatoire manquant, donnée corrompue ou bug de déploiement. Enregistrez un identifiant de corrélation dans vos logs, par exemple requestId, orderId ou customerId.
La documentation officielle Symfony recommande aussi de prévoir l’échec comme un flux opérationnel, pas comme une exception invisible. Dans les équipes qui automatisent leurs déploiements, cette exigence complète les pratiques de CI/CD Symfony avec GitLab ou GitHub Actions : un pipeline vert ne garantit pas que les workers consomment réellement les messages après mise en production.
Superviser les workers avec Supervisor ou systemd
Un worker Messenger est un processus long. Le lancer dans un terminal SSH fonctionne pour un test, jamais comme stratégie de production.
La commande de base est :
php bin/console messenger:consume async_email async_reports --time-limit=3600 --memory-limit=256M --failure-limit=10
Les limites de temps et de mémoire permettent un redémarrage régulier. C’est utile après un déploiement, pour libérer d’éventuelles fuites mémoire ou renouveler les connexions à des services externes.
Avec Supervisor, un fichier de configuration peut ressembler à ceci :
[program:messenger-consume]
command=/usr/bin/php /var/www/app/bin/console messenger:consume async_email async_reports --time-limit=3600 --memory-limit=256M
directory=/var/www/app
user=www-data
numprocs=2
autostart=true
autorestart=true
startretries=10
redirect_stderr=true
stdout_logfile=/var/log/supervisor/messenger.log
stopwaitsecs=20
Avec systemd, préférez une unité explicite :
[Unit]
Description=Symfony Messenger Worker
After=network.target
[Service]
User=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php bin/console messenger:consume async_email async_reports --time-limit=3600 --memory-limit=256M
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Après un déploiement, demandez aux workers de terminer proprement :
php bin/console messenger:stop-workers
Les processus actifs détectent ce signal et s’arrêtent après le message courant. Votre superviseur les redémarre ensuite avec le nouveau code. Cette procédure limite le risque de traiter un message avec une ancienne version du handler.
Surveillez au minimum :
- le nombre de messages en attente par file ;
- l’âge du plus ancien message ;
- le volume de messages en échec ;
- le nombre de redémarrages de workers ;
- la durée moyenne des handlers ;
- les erreurs réseau vers SMTP, stockage ou API tierces.
Un worker unique est un point de saturation. Cependant, multiplier les workers sans limite peut épuiser la base de données, l’API CRM ou le service de génération de PDF. Ajustez la concurrence par type de tâche. Deux workers d’exports lourds peuvent être préférables à vingt, tandis que l’envoi d’e-mails peut supporter davantage de parallélisme.
Cas concrets et pièges qui causent lenteur ou perte de messages
L’envoi d’e-mails est le cas d’usage le plus accessible. Après la création d’un compte, persistez l’utilisateur, puis publiez SendWelcomeEmail. Si le mailer échoue, le compte reste créé et Messenger retente l’envoi. Ajoutez un marqueur métier pour ne pas envoyer deux fois le même e-mail après une reprise.
La génération de rapports demande une approche différente. Ne gardez pas une énorme collection Doctrine en mémoire. Traitez les données par lots, écrivez progressivement le fichier et stockez-le sur un volume ou un objet storage. Si le rapport est volumineux, créez un enregistrement Report avec un statut pending, passez son identifiant au message, puis faites évoluer son statut vers ready ou failed.
Pour une synchronisation CRM, ne faites pas un appel HTTP à chaque modification de champ si votre domaine produit beaucoup d’événements. Vous pouvez agréger, dédupliquer ou ne conserver que la dernière version d’un client. Chaque requête HTTP doit avoir :
- un timeout de connexion ;
- un timeout total ;
- une journalisation sans données sensibles ;
- une gestion spécifique des erreurs 4xx et 5xx ;
- une clé d’idempotence si l’API la supporte.
Ces files de messages exposent aussi une surface d’attaque à ne pas négliger : un transport mal sécurisé ou des credentials de broker mal protégés peuvent devenir un point d’entrée. Le magazine jthinformatique.com publie régulièrement des enquêtes sur la sécurisation des architectures numériques exposées, une lecture utile en complément de la supervision applicative de Messenger.
Attention aussi à la perte de messages lors d’une transaction. Cet anti-pattern est courant :
$this->entityManager->persist($order);
$this->entityManager->flush();
$this->bus->dispatch(new SendOrderConfirmation($order->getId()));
Si le processus tombe entre les deux opérations, la commande est enregistrée mais le message n’est jamais publié. Pour les flux où cette incohérence est inacceptable, utilisez une outbox transactionnelle ou un mécanisme équivalent.
Enfin, mesurez avant d’optimiser. Doctrine Messenger peut suffire longtemps, mais une file qui grossit durablement révèle souvent un handler lent, une dépendance externe saturée ou un nombre insuffisant de workers. Les outils d’observabilité, les logs structurés et les tests E2E sont essentiels ; notre guide sur les tests E2E Symfony avec Playwright et Panther aide à vérifier les parcours utilisateur, tandis que Messenger nécessite en complément des tests de handler et des scénarios de panne.
Symfony Messenger devient réellement fiable lorsque vous traitez chaque message comme une opération distribuée : retard possible, doublon possible, échec possible. Avec des messages petits, des handlers idempotents, une file d’échec examinée et des workers supervisés, l’asynchrone améliore nettement la réactivité de vos applications PHP sans transformer votre production en boîte noire.