Skip to Content
SymfonyPartie 12 — Temps réel & asynchrone12.1 — Messenger : le bus de messages

Chapitre 12.1 — Messenger : le bus de messages

Partie 12 : Temps réel & async — Chapitre 1/7 Version de référence : Symfony 7.4 (composant Messenger).

⏱️ TL;DR

  • Messenger implémente le pattern bus de messages : on dispatche un message (une classe de données), et un handler (annoté #[AsMessageHandler]) le traite.
  • dispatch(new SendBookingConfirmation($id)) : le contrôleur délègue un travail sans le faire lui-même.
  • Par défaut, un message est traité immédiatement (synchrone) ; routé vers un transport async (chapitre 12.2), il est traité par un worker séparé — d’où le « travail en arrière-plan » (rappel Partie 2.7 : pas d’async/await, on utilise des workers).
  • Un message = data pure (souvent des ids, pas des entités). Un handler = la logique. Découplage total.
  • Pour un dev Next.js : Messenger ≈ BullMQ / une file de jobs — mais intégré au conteneur et typé par classe de message.

🎯 Objectifs

  • Créer un message et son handler.
  • Dispatcher un message depuis un contrôleur/service.
  • Comprendre le rôle du bus et de l’enveloppe.
  • Savoir ce qui rend un message « async ».

1. Le pattern : message → bus → handler

  • Un message décrit quoi faire (des données).
  • Le bus l’achemine vers son handler.
  • Le handler fait le travail.

L’émetteur (contrôleur) ne connaît pas le handler : il dispatche, c’est tout. Ce découplage permet de traiter le message maintenant (sync) ou plus tard (async, worker) sans changer l’émetteur.

2. Un message

Un message est une classe de données simple, souvent immuable, transportant le minimum (des identifiants) :

// src/Message/SendBookingConfirmation.php declare(strict_types=1); namespace App\Message; final readonly class SendBookingConfirmation { public function __construct( public int $bookingId, // ← un ID, pas l'entité entière ) {} }

⚠️ Piège : ne mettez pas une entité Doctrine complète dans un message asynchrone. Le message est sérialisé (JSON) pour être stocké dans la file, puis désérialisé par le worker — une entité s’y prête mal (état périmé, relations, EntityManager déconnecté). Transportez des ids (ou un petit DTO), et rechargez l’entité fraîche depuis la base dans le handler. C’est plus sûr et plus léger.

3. Un handler

Le handler traite le message. Une classe #[AsMessageHandler] avec un __invoke() typé sur le message :

// src/MessageHandler/SendBookingConfirmationHandler.php declare(strict_types=1); namespace App\MessageHandler; use App\Message\SendBookingConfirmation; use App\Repository\BookingRepository; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final class SendBookingConfirmationHandler { public function __construct( private readonly BookingRepository $bookings, private readonly MailerInterface $mailer, ) {} public function __invoke(SendBookingConfirmation $message): void { $booking = $this->bookings->find($message->bookingId); // recharge l'entité fraîche if ($booking === null) { return; // supprimée entre-temps → on ignore } // ... construire et envoyer l'e-mail de confirmation } }

Le lien message↔handler se fait par le type de l’argument de __invoke (SendBookingConfirmation). Le handler est autoconfiguré (Partie 5.3) : aucune config, et il reçoit ses dépendances par injection.

4. Dispatcher un message

On injecte MessageBusInterface et on dispatch :

use Symfony\Component\Messenger\MessageBusInterface; final class BookingCreator { public function __construct( private readonly EntityManagerInterface $em, private readonly MessageBusInterface $bus, ) {} public function create(CreateBookingInput $input): Booking { $booking = /* ... */; $this->em->persist($booking); $this->em->flush(); // délègue l'e-mail : le contrôleur répond tout de suite $this->bus->dispatch(new SendBookingConfirmation($booking->getId())); return $booking; } }

Le point clé : create() rend la main immédiatement après le dispatch. L’e-mail sera envoyé par le handler — maintenant (sync) ou en arrière-plan (async), selon le routage du message (chapitre 12.2).

5. Bus de commandes vs bus d’événements (conceptuel)

Messenger sert deux intentions :

  • Commande : « fais ceci » — un message, un handler (SendBookingConfirmation). Impératif.
  • Événement : « ceci s’est produit » — un message, plusieurs handlers possibles (chacun réagit). Proche de l’EventDispatcher (Partie 5.5), mais distribuable en async.

Symfony permet de configurer plusieurs bus (command.bus, event.bus, query.bus) — un socle pour le pattern CQRS (Partie 15). Par défaut, un seul bus suffit.

6. L’enveloppe et les stamps

Quand on dispatche, Messenger emballe le message dans une enveloppe (Envelope) portant des stamps (métadonnées) : délai (DelayStamp), transport visé, priorité, etc.

use Symfony\Component\Messenger\Stamp\DelayStamp; // envoyer le rappel dans 1 heure $this->bus->dispatch(new SendReminder($bookingId), [new DelayStamp(3_600_000)]); // ms

Les stamps permettent de contrôler l’acheminement sans changer le message lui-même.

💡 Pour un dev Next.js : Messenger ≈ BullMQ (ou une file SQS). Le message ≈ le job payload, le handler ≈ le processor, le dispatch ≈ queue.add(). La différence : c’est typé par classe et autoconfiguré (pas de nom de queue en string à câbler). Et — rappel Partie 2.7 — c’est le mécanisme de « travail en arrière-plan » en PHP, à la place de l’async/await.

✏️ Exercices

Exercice 1. Créez un message SendProviderWelcome(int $providerId) et le squelette de son handler.

✅ Solution

// Message final readonly class SendProviderWelcome { public function __construct(public int $providerId) {} } // Handler #[AsMessageHandler] final class SendProviderWelcomeHandler { public function __construct( private readonly ProviderRepository $providers, private readonly MailerInterface $mailer, ) {} public function __invoke(SendProviderWelcome $message): void { $provider = $this->providers->find($message->providerId); if ($provider === null) { return; } // ... envoyer l'e-mail de bienvenue } }

Le message transporte un id ; le handler recharge l’entité fraîche.

Exercice 2. Pourquoi transporter bookingId plutôt que l’objet Booking dans le message ?

✅ Solution

Parce qu’un message async est sérialisé (JSON) pour la file puis désérialisé par le worker : une entité Doctrine s’y prête mal (état potentiellement périmé au moment du traitement, relations lazy, EntityManager déconnecté). Transporter l’id garde le message léger et laisse le handler recharger l’entité fraîche depuis la base au moment du traitement — plus sûr et plus juste.

Exercice 3. Après dispatch, BookingCreator::create() rend-il la main immédiatement ? De quoi cela dépend-il ?

✅ Solution

Cela dépend du routage du message. Si le message est routé vers un transport async (chapitre 12.2), dispatch enfile le message et rend la main immédiatement (le handler s’exécute plus tard dans un worker). Si le message reste synchrone (transport sync, défaut), le handler s’exécute dans l’appel à dispatch — donc create() attend qu’il finisse. Pour un e-mail, on veut de l’async.

🧠 Quiz de révision

1. Quels sont les trois éléments du pattern Messenger ?

Un message (données), le bus (achemine), et un handler (#[AsMessageHandler], traite). L’émetteur ne connaît pas le handler.

2. Comment relie-t-on un handler à un message ?

Par le type de l’argument de __invoke(MonMessage $m) ; le handler est autoconfiguré.

3. Que transporte-t-on de préférence dans un message async ?

Des ids (ou un petit DTO), pas des entités Doctrine — le handler recharge l’entité fraîche.

4. Qu’est-ce qui détermine si un message est traité maintenant ou plus tard ?

Son routage vers un transport sync (immédiat) ou async (worker, chapitre 12.2).

5. À quoi servent les stamps ?

Ce sont des métadonnées sur l’enveloppe du message (délai, transport, priorité…) qui contrôlent l’acheminement sans modifier le message.


Prochain chapitre : 12.2 — Async, retries, échecs & workers — router vers un transport, consommer, et gérer les échecs.