Tous les articles
Mer. 18 février 2026 · 3 min de lecture

✉️ Symfony Messenger : penser ses jobs asynchrones autrement

Symfony Messenger

📚 Introduction

Quand on arrive sur Symfony Messenger en venant de Laravel Horizon, de Sidekiq ou de Bull, le premier réflexe est d’y voir un simple gestionnaire de queue : on définit un job, on le dispatch, un worker l’exécute. Ça marche, mais on passe à côté de l’essentiel.

Messenger est avant tout un bus de messages. Chaque message porte une intention métier (“envoyer une facture”, “synchroniser un utilisateur”, “publier un événement”), et le bus se charge de le router vers le bon handler, qu’il soit synchrone ou asynchrone, local ou distribué. Cette nuance change beaucoup de choses dans l’architecture d’une application Symfony.

🧩 Un message, un handler, une intention

La règle d’or que je m’impose sur tous mes projets Symfony : un message décrit une intention métier immuable, pas un appel technique.

// ✅ Bonne intention
final readonly class SendInvoiceEmail
{
	public function __construct(
		public string $invoiceId,
		public string $recipientEmail,
	) {}
}
 
// ❌ Mauvaise intention (trop technique)
final readonly class CallSmtpService
{
	public function __construct(
		public string $body,
		public string $subject,
	) {}
}

Le premier décrit ce que veut faire le métier (“envoyer la facture X au client Y”), le second décrit comment le code l’exécute aujourd’hui. Le premier survit aux refactors, le second non.

Le handler associé reste simple :

#[AsMessageHandler]
final class SendInvoiceEmailHandler
{
	public function __construct(
		private readonly InvoiceRepository $invoices,
		private readonly Mailer $mailer,
	) {}
 
	public function __invoke(SendInvoiceEmail $message): void
	{
		$invoice = $this->invoices->find($message->invoiceId);
		$this->mailer->send($invoice, $message->recipientEmail);
	}
}

🛣️ Synchrone ou asynchrone : juste de la configuration

Le grand atout de Messenger, c’est que la décision “synchrone ou asynchrone” est une simple ligne de configuration, pas un changement de code applicatif :

# config/packages/messenger.yaml
framework:
  messenger:
    transports:
      async: '%env(MESSENGER_TRANSPORT_DSN)%'
    routing:
      App\Message\SendInvoiceEmail: async
      App\Message\PingExternalService: sync

Conséquence : pendant le développement, on peut tout exécuter en synchrone pour simplifier le debug, et basculer en asynchrone en staging et production sans toucher aux handlers.

🧱 Middleware et stamps : les vrais super-pouvoirs

Là où Messenger se distingue vraiment, c’est sur deux mécanismes secondaires souvent sous-utilisés :

  • Les middlewares s’exécutent autour de la chaîne de dispatch (validation, transaction de base de données, audit logging, etc.). On peut en empiler plusieurs sans toucher au code des handlers.
  • Les stamps sont des métadonnées attachées au message au moment du dispatch : delay, priority, dedup id, transport name. Ils permettent d’enrichir le routage sans modifier la signature du message.
$this->bus->dispatch(
	new SendInvoiceEmail($invoiceId, $email),
	[new DelayStamp(60_000)] // attendre 60 secondes avant exécution
);

C’est avec ces deux primitives qu’on construit du retry intelligent, du rate limiting, ou de la déduplication de messages, sans polluer la logique métier.

⚠️ Les pièges classiques

Quelques erreurs que j’ai vu (ou commis) plusieurs fois :

  • Sérialiser des entités Doctrine dans un message. Quand le worker reprend le message, l’entité n’est plus rattachée à l’EntityManager : il faut passer l’identifiant et recharger.
  • Oublier MessageBus::dispatch() dans une transaction. Si le dispatch se fait avant le commit, le worker peut lire un état qui n’existe pas encore en base. Utiliser le middleware DoctrineTransactionMiddleware ou dispatcher après le flush.
  • Faire des handlers trop gros. Un handler doit faire une seule chose. Si l’intention couvre plusieurs actions, dispatchez d’autres messages depuis le handler.
  • Négliger la stratégie de retry et de DLQ (Dead Letter Queue). Sans configuration explicite, un message qui échoue boucle indéfiniment. Configurez retry + transport failed dès le premier message.

🚀 Aller plus loin

  • Activer Doctrine ou Redis comme transport dès que l’application a un vrai trafic. Le transport sync est utile pour le dev, pas pour la production.
  • Utiliser Symfony Profiler sur les environnements non-prod : il affiche les messages dispatchés, les handlers appelés et les stamps attachés, ce qui aide énormément à debugger.
  • Coupler à Symfony Workflow pour modéliser les états des messages longs (commande, livraison, retours…) plutôt que d’empiler des booléens dans une entité.

🎉 Conclusion

Symfony Messenger ne se résume pas à “Symfony fait des queues maintenant”. C’est un vrai bus de messages qui invite à structurer son code autour d’intentions métier, à séparer proprement la décision (dispatch) de l’exécution (handler) et à laisser l’infrastructure (transport, retry, audit) en dehors du code applicatif.

Une fois ce déclic posé, on écrit du code Symfony nettement plus lisible et plus facile à faire évoluer. Le composant mérite largement le temps qu’on lui consacre.

🔗 Liens utiles