Quand une entité change d'état, qui réagit ?
Quand une entité change d'état, qui est responsable de ce qui se passe ensuite ?
Tu viens d'appeler Course::publish(). L'entité change d'état, elle passe de
brouillon à publiée. Bien. Mais maintenant ?
Quelqu'un doit envoyer un email aux abonnés. Quelqu'un doit mettre à jour l'index de recherche. Quelqu'un doit incrémenter le compteur de cours publiés dans le tableau de bord. Trois réactions distinctes, toutes déclenchées par le même fait : la publication d'un cours.
L'instinct naturel, c'est de tout faire dans l'entité elle-même — après tout, c'est elle qui "sait" qu'elle vient d'être publiée. Ou alors de tout empiler dans le handler, ce service applicatif qui orchestre les opérations : il appelle la méthode, puis chaîne les effets secondaires un par un.
Imagine un distributeur de tickets dans une administration. Quand il imprime ton ticket, il ne sait pas — et ne devrait pas savoir — si un écran va s'allumer, si une sonnerie va retentir, si un agent va être notifié. Il fait une seule chose : enregistrer le fait que tu es arrivé. Les conséquences sont gérées ailleurs, par les systèmes qui ont décidé de s'intéresser à cet événement.
Les Domain Events fonctionnent exactement comme ça. Dans ce cours, tu vas voir :
- pourquoi l'approche naïve (service dans l'entité, ou handler surchargé) crée des problèmes réels dès que le projet grandit ;
- ce qu'est un Domain Event — un fait passé, immuable, nommé au passé ;
- comment l'implémenter en PHP pur, sans framework ;
- comment le brancher dans Symfony avec Doctrine et Messenger ;
- et les trois pièges classiques à éviter.
Le problème : injecter un service dans une entité
Injecter un service dans une entité semble pratique. Ça crée un couplage que tu regretteras.
Commençons par l'approche la plus directe : faire en sorte que l'entité gère elle-même ses
effets secondaires. Si Course est publiée et qu'elle doit notifier des abonnés,
pourquoi ne pas lui passer un service de notification ?
// src/Domain/Course/Course.php use App\Domain\Course\CourseId; use App\Domain\Course\CourseStatus; use App\Infrastructure\Notification\NotificationService; final class Course { private CourseStatus $status; public function __construct( private readonly CourseId $id, private string $title, ) { $this->status = CourseStatus::Draft; } public function publish(NotificationService $notifier): void { $this->status = CourseStatus::Published; // On notifie les abonnés directement depuis l'entité $notifier->notifySubscribers($this->id, $this->title); } }
Une entité de domaine contient les règles métier : les invariants, les transitions d'état valides, les contraintes. Elle ne devrait pas contenir de logique d'infrastructure — c'est-à-dire tout ce qui parle à l'extérieur du processus : emails, files de messages, moteurs de recherche, API tierces.
Le problème avec ce code ? NotificationService est un service d'infrastructure.
En l'injectant dans l'entité, tu viens de créer une dépendance directe entre ton domaine pur
et une couche technique. Trois conséquences immédiates :
- Couplage fort : si la façon dont on envoie des notifications change (on passe de SMTP à un broker de messages, par exemple), tu dois modifier l'entité — une classe qui ne devrait pas être touchée pour ce type de raison.
-
Tests difficiles : pour tester la simple transition d'état de
Course, tu dois désormais instancier ou mocker unNotificationService. Les tests unitaires de ton domaine deviennent des tests d'intégration déguisés. -
Violation du principe de responsabilité unique : l'entité fait deux
choses — elle gère son état et déclenche des effets secondaires. Si demain il
faut aussi mettre à jour un index de recherche, tu ajoutes un deuxième paramètre. Puis
un troisième. Le constructeur de
publish()devient une liste à rallonge.
Alors on se dit : mettons tout ça dans le handler. L'entité reste pure, et le handler orchestre les effets secondaires après la sauvegarde.
// Dans un handler applicatif public function __invoke(Input $input): void { $course = $this->courses->findById($input->courseId); $course->publish(); $this->courses->save($course); // Effets secondaires chaînés manuellement $this->notifier->notifySubscribers($course); $this->searchIndex->index($course); $this->stats->increment('courses.published'); }
C'est mieux — l'entité est pure. Mais le handler accumule maintenant tous les effets secondaires. Il connaît tous les systèmes qui doivent réagir à une publication. Quand on ajoute un nouveau comportement (un webhook, une entrée dans un journal d'audit), on revient modifier ce fichier. Le handler devient un point de couplage central.
Il faut un mécanisme qui permet à l'entité de signaler ce qui s'est passé, sans savoir — ni se soucier — de ce qui se passe ensuite. C'est exactement ce que les Domain Events permettent de faire.
Les Domain Events : un fait, pas une instruction
Un événement de domaine dit ce qui s'est passé — pas ce qui doit être fait.
Un Domain Event, c'est la représentation explicite d'un fait métier significatif — exprimé dans le langage du domaine. Pas une intention, pas une instruction : un constat. Quelque chose s'est produit, c'est irrévocable, et d'autres parties du système peuvent en être informées.
L'analogie la plus juste : pense à un journal de bord de bord sur un bateau. Quand le capitaine note "13h42 — cap mis à l'ouest", il ne donne pas d'ordre à l'équipage. Il enregistre un fait. L'ingénieur moteur, le cuisinier, le navigateur — chacun lit ce journal et décide s'il doit réagir ou non. Le capitaine ne sait pas, et ne se soucie pas, de ce que chacun va en faire.
Un objet immuable qui représente quelque chose qui s'est passé dans le domaine. Il est nommé au passé, exprime un fait métier précis, et porte les informations nécessaires pour que les observateurs puissent réagir sans aller chercher d'autres données.
En PHP, un Domain Event se modélise simplement. Une interface commune garantit qu'on peut récupérer quand l'événement s'est produit — c'est souvent la seule obligation partagée :
// src/Domain/Event/DomainEvent.php declare(strict_types=1); namespace App\Domain\Event; interface DomainEvent { public function occurredAt(): \DateTimeImmutable; }
Ensuite, chaque événement est une classe concrète. Le nom est crucial : il suit l'Ubiquitous Language du domaine et se lit comme une phrase du passé.
// src/Domain/Event/CourseWasPublished.php declare(strict_types=1); namespace App\Domain\Event; use App\Domain\Course\Course; use App\Domain\Event\DomainEvent; final readonly class CourseWasPublished implements DomainEvent { public function __construct( public readonly Course $course, private readonly \DateTimeImmutable $occurredAt, ) {} public function occurredAt(): \DateTimeImmutable { return $this->occurredAt; } }
CourseWasPublished, pas PublishEvent ni CoursePublisher.
Le nom au passé composé dit exactement ce qui s'est passé, dans le vocabulaire du métier.
Un non-développeur qui lit ce nom comprend immédiatement : un cours a été publié. Ce niveau
de clarté n'est pas cosmétique — c'est ce qui permet à toute une équipe de parler du même
concept sans ambiguïté.
Ce que tu remarques dans cette classe : elle est immuable. Pas de setter, tout est défini à la construction. Un événement passé ne peut pas être modifié — c'est une contrainte fondamentale. On peut l'observer, pas l'altérer.
L'entité qui crée cet événement — ici Course — ne sait pas du tout ce qui va
se passer ensuite. Elle ne connaît pas le service de notification, ni le moteur de recherche,
ni le compteur de statistiques. Elle fait une seule chose : déclarer un fait.
Les listeners — les observateurs de cet événement — décident chacun de leur côté quoi en faire.
Course. Tu crées un nouveau listener.
L'entité ignore son existence.
Une commande exprime une intention : "publie ce cours". Elle peut être refusée, retardée, ou modifiée. Un événement exprime un fait accompli : "ce cours a été publié". Il ne peut pas être refusé — il s'est déjà produit. La commande est impérative (faites ceci), l'événement est déclaratif (ceci s'est passé). Cette asymétrie est au cœur de leur rôle différent dans le domaine.
C'est une distinction que tu rencontreras souvent dans les architectures pilotées par les événements : les commandes traversent le système de l'extérieur vers l'intérieur, les événements rayonnent de l'intérieur vers l'extérieur. Les unes déclenchent un traitement, les autres en annoncent la conclusion.
Implémentation PHP : collecter avant de dispatcher
L'entité collecte ses événements. Quelqu'un d'autre les dispatche.
Tu sais maintenant ce qu'est un Domain Event — un fait métier immuable. La question pratique : comment l'entité le "produit-elle" sans dispatcher quoi que ce soit ? La réponse tient en un mot : elle collecte. Les événements s'accumulent dans un tableau interne, et c'est quelqu'un d'autre — en dehors de l'entité — qui les dispatch au bon moment.
Le DomainEventsTrait
Plutôt que d'ajouter cette mécanique manuellement dans chaque entité, on la factorise dans un
trait. C'est l'approche la plus propre : l'entité déclare use DomainEventsTrait;
et hérite immédiatement de la capacité de collecter ses événements.
// src/Domain/Event/DomainEventsTrait.php declare(strict_types=1); namespace App\Domain\Event; use App\Domain\Event\DomainEvent; trait DomainEventsTrait { /** @var DomainEvent[] */ private array $domainEvents = []; protected function recordEvent(DomainEvent $event): void { $this->domainEvents[] = $event; } /** @return DomainEvent[] */ public function releaseEvents(): array { $events = $this->domainEvents; $this->domainEvents = []; return $events; } public function hasEvents(): bool { return $this->domainEvents !== []; } }
Trois méthodes, trois rôles distincts :
-
recordEvent()— protégée, appelée depuis l'intérieur de l'entité dans chaque méthode métier. Elle pousse l'événement dans le tableau. -
releaseEvents()— publique, appelée par l'infrastructure après la persistance. Elle retourne tous les événements collectés et vide le tableau (chaque événement n'est dispatché qu'une seule fois). -
hasEvents()— utilitaire pour savoir si l'entité a des événements en attente, sans les consommer.
L'entité qui use le trait
Voilà comment Course l'utilise. La méthode publish() ne reçoit plus
aucun service en paramètre — elle enregistre simplement un fait.
// src/Domain/Course/Course.php declare(strict_types=1); namespace App\Domain\Course; use App\Domain\Event\CourseWasPublished; use App\Domain\Event\DomainEventsTrait; final class Course { use DomainEventsTrait; private CourseStatus $status; public function __construct( private readonly CourseId $id, private string $title, ) { $this->status = CourseStatus::Draft; } public function publish(): void { if ($this->status === CourseStatus::Published) { return; } $this->status = CourseStatus::Published; // On enregistre le fait — on ne dispatch rien $this->recordEvent(new CourseWasPublished( $this, new \DateTimeImmutable(), )); } }
Remarque ce que publish() ne fait pas : elle n'envoie pas d'email, elle
ne parle à aucun service, elle ne connaît pas le mot "notification". Elle change l'état de
l'entité et pose un événement dans sa propre file d'attente. C'est tout.
Le flux de collecte
Voilà comment se déroule le cycle de vie d'un événement, de sa naissance à son dispatch :
publish()recordEvent()$domainEvents[]tableau interne
releaseEvents()(plus tard)
L'entité ne fait que progresser vers la droite jusqu'au troisième bloc. Le quatrième —
releaseEvents() — est appelé par l'infrastructure, après la transaction.
L'entité ne sait pas quand cela se produit.
L'entité collecte ses événements sans les dispatcher. Elle est l'auteur du fait — elle sait ce qui s'est passé — mais elle délègue la publication à l'extérieur. Cette séparation est fondamentale : le fait métier appartient au domaine, la réaction appartient à l'infrastructure. Tant que la transaction n'est pas confirmée, personne n'a encore été notifié. Si la base de données rejette la transaction, les événements collectés sont simplement abandonnés — aucun observateur n'aura été déclenché pour rien.
Pourquoi pas un dispatch direct ?
On pourrait imaginer que publish() appelle directement un dispatcher d'événements
Symfony — après tout, c'est plus court. Voilà pourquoi c'est une mauvaise idée.
Dispatch direct dans l'entité
- L'entité dépend d'un service Symfony → plus de domaine pur
- L'email part avant que la transaction soit commitée
- Si Doctrine rollback, l'email est déjà envoyé : incohérence
- Impossible de tester l'entité sans le container Symfony
Collecte + release après transaction
- L'entité reste pure — aucune dépendance framework
- Les événements ne sont dispatchés qu'après le commit
- Si la transaction échoue, les événements sont silencieusement abandonnés
- Les tests unitaires n'ont besoin que de PHP vanilla
Le tableau interne agit comme un buffer transactionnel. Rien ne sort de l'entité tant que le système n'a pas confirmé que l'état a bien été persisté.
recordEvent() depuis le handler ou
depuis un service applicatif revient à contourner l'entité — l'événement n'est alors plus
ancré dans la logique métier, et on perd toute garantie qu'il correspond à une vraie
transition d'état valide.
La prochaine section montre comment l'infrastructure récupère ces événements et les injecte dans Symfony Messenger — sans que l'entité en sache quoi que ce soit.
Branchement Symfony : le pont entre domaine et infrastructure
Un subscriber Doctrine fait le pont entre ton domaine et le reste de l'application.
L'entité collecte ses événements. Parfait. Mais quelqu'un doit les récupérer et les dispatcher au bon moment — après que Doctrine a confirmé la persistance. Ce rôle appartient à un subscriber Doctrine : une classe d'infrastructure qui écoute le cycle de vie de l'ORM et fait le pont entre ton domaine et le reste de l'application.
Pourquoi écouter postFlush ?
Doctrine émet plusieurs événements autour de la persistance. Deux d'entre eux vont nous
intéresser pour collecter les entités à inspecter : postPersist et
postUpdate. Et un troisième pour dispatcher : postFlush.
La distinction est fondamentale. Imagine une transaction bancaire : on ne prévient le client
que lorsque le virement est confirmé, pas au moment où la demande est mise en file d'attente.
postFlush, c'est le moment où Doctrine a committé la transaction. La base de
données a accepté les changements. C'est à ce moment-là — et pas avant — qu'il est raisonnable
de dispatcher des événements, parce qu'on est certain que l'état persisté correspond à ce que
les événements annoncent.
postPersist et postUpdate se déclenchent entité par entité, pendant
le flush, avant que la transaction soit nécessairement terminée. postFlush se
déclenche une seule fois, après que toutes les entités ont été écrites et la transaction
commitée. C'est le seul moment sûr pour dispatcher des effets secondaires.
Le DomainEventSubscriber
Voici la classe complète. Elle implémente EventSubscriberInterface de Doctrine
— pas de Symfony — et travaille avec le EventDispatcherInterface de Symfony
pour dispatcher les événements une fois collectés.
// src/Infrastructure/Doctrine/DomainEventSubscriber.php declare(strict_types=1); namespace App\Infrastructure\Doctrine; use App\Domain\Event\DomainEvent; use Doctrine\Common\EventSubscriber; use Doctrine\ORM\Events; use Doctrine\ORM\Event\PostFlushEventArgs; use Doctrine\ORM\Event\PostPersistEventArgs; use Doctrine\ORM\Event\PostUpdateEventArgs; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; final class DomainEventSubscriber implements EventSubscriber { /** @var object[] Entités dont on va collecter les événements */ private array $entities = []; public function __construct( private readonly EventDispatcherInterface $dispatcher, ) {} /** Déclare quels événements Doctrine on écoute */ public function getSubscribedEvents(): array { return [ Events::postPersist, Events::postUpdate, Events::postFlush, ]; } /** * Appelé après chaque INSERT : on met l'entité de côté. * On ne dispatche pas encore — la transaction n'est pas terminée. */ public function postPersist(PostPersistEventArgs $args): void { $this->entities[] = $args->getObject(); } /** * Appelé après chaque UPDATE : même traitement. */ public function postUpdate(PostUpdateEventArgs $args): void { $this->entities[] = $args->getObject(); } /** * Appelé une seule fois, après que le flush complet est terminé. * La transaction est commitée : on peut dispatcher en toute sécurité. */ public function postFlush(PostFlushEventArgs $args): void { // On copie et vide le tableau avant d'itérer // pour éviter tout risque de boucle infinie si un flush // était déclenché depuis un listener. $entities = $this->entities; $this->entities = []; foreach ($entities as $entity) { if (!method_exists($entity, 'releaseEvents')) { continue; } /** @var DomainEvent $event */ foreach ($entity->releaseEvents() as $event) { // dispatch() de Symfony prend le nom de la classe comme identifiant $this->dispatcher->dispatch($event); } } } }
$this->entities avant d'itérer ? Si un listener
déclenche une modification Doctrine et un nouveau flush pendant la boucle, les nouvelles entités
s'ajouteraient dans le tableau en cours d'itération. Copier et vider d'abord protège contre
cette situation. C'est un réflexe de défense simple mais important.
method_exists() au lieu d'une interface ? Le method_exists()
est un compromis pragmatique : il évite d'imposer une interface HasDomainEvents à toutes les entités
du domaine. Si tu préfères la rigueur du typage, tu peux créer cette interface et la faire implémenter par tes
entités — c'est une question de style architectural. Pour les petits projets ou un domaine divers, l'approche
sans interface suffit. Pour les gros projets, l'interface te force à être explicite et c'est plus testable.
Le flux complet
Le chemin d'un événement, depuis le flush Doctrine jusqu'au listener applicatif :
flush()postFlushDoctrine
releaseEvents()entité
dispatch()Symfony
#[AsEventListener]listener
Doctrine confirme la transaction, puis notre subscriber entre en jeu. Il demande à chaque entité de libérer ses événements collectés, et les injecte dans le bus d'événements Symfony. De là, tous les listeners enregistrés sur le type de cet événement sont appelés.
Écrire un listener avec #[AsEventListener]
Un listener Symfony, c'est simplement une classe avec un attribut PHP. Pas besoin de configuration YAML ni d'implémenter une interface : l'attribut suffit pour que Symfony découvre et enregistre le listener automatiquement.
// src/Infrastructure/Listener/LogCoursePublicationListener.php declare(strict_types=1); namespace App\Infrastructure\Listener; use App\Domain\Event\CourseWasPublished; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; final class LogCoursePublicationListener { public function __construct( private readonly LoggerInterface $logger, ) {} #[AsEventListener] public function __invoke(CourseWasPublished $event): void { $this->logger->info('Cours publié', [ 'title' => $event->course->getTitle(), 'occurred_at' => $event->occurredAt()->format(\DateTimeInterface::ATOM), ]); } }
Symfony déduit automatiquement l'événement à écouter à partir du type de l'argument de
__invoke(). Quand tu dispatcheras un objet CourseWasPublished, ce
listener sera appelé. Tu peux en créer autant que nécessaire — un pour les logs, un pour les
emails, un pour l'index de recherche — sans modifier ni le listener existant ni l'entité.
Symfony supporte aussi des méthodes nommées (pas seulement
__invoke()) :
#[AsEventListener(event: CourseWasPublished::class, method: 'onCoursePublished')].
Cette forme est utile si une même classe écoute plusieurs types d'événements. Pour une classe
à responsabilité unique — un listener, un événement — la forme __invoke() est
la plus concise et la plus lisible.
$em->persist() ou $em->flush() pendant le cycle
postFlush, tu risques une boucle de flush ou un comportement imprévisible —
Doctrine n'est pas conçu pour des modifications imbriquées à ce stade. Pour des réactions
complexes qui impliquent d'écrire en base (créer un log d'audit, mettre à jour un agrégat
différent), utilise plutôt Symfony Messenger : le listener envoie un message
dans la file, et le handler correspondant s'exécute dans un cycle Doctrine propre, hors de
la transaction d'origine.
Maintenant que le mécanisme est en place et que tu sais comment le brancher dans Symfony, il est temps de voir les trois pièges qui guettent quand on l'adopte — et comment les éviter.
Erreurs courantes : trois pièges à éviter
Trois pièges faciles à éviter une fois qu'on les connaît.
Tu connais maintenant le mécanisme dans les grandes lignes : l'entité collecte, le subscriber Doctrine dispatche après le flush, le listener réagit. Mais en pratique, trois pièges reviennent systématiquement quand on adopte les Domain Events — des erreurs qui ne cassent pas immédiatement le code, mais qui créent des incohérences difficiles à déboguer plus tard. Voilà comment les reconnaître et les corriger.
Piège 1 : enregistrer l'événement depuis le handler
Quand on découvre les Domain Events, la tentation est de garder l'entité entièrement neutre et de gérer l'enregistrement de l'événement depuis le code applicatif — après tout, c'est le handler qui sait qu'une publication vient d'avoir lieu, non ?
❌ Événement enregistré dans le handler
// Dans le handler applicatif public function __invoke(Input $input): void { $course = $this->courses->findById($input->courseId); // La méthode de l'entité ne fait que changer l'état $course->publish(); // L'événement est enregistré ICI, en dehors de l'entité $course->recordEvent(new CourseWasPublished( $course, new \DateTimeImmutable(), )); $this->courses->save($course); }
✅ Événement enregistré dans l'entité
// Dans l'entité Course public function publish(): void { if ($this->status === CourseStatus::Published) { return; } $this->status = CourseStatus::Published; // L'événement naît ici, ancré dans la transition d'état $this->recordEvent(new CourseWasPublished( $this, new \DateTimeImmutable(), )); } // Dans le handler : rien à faire côté événement public function __invoke(Input $input): void { $course = $this->courses->findById($input->courseId); $course->publish(); $this->courses->save($course); }
Le problème de la colonne de gauche est subtil mais réel : si publish() contient
une garde ("si le cours est déjà publié, ne rien faire"), le handler ne le sait pas.
Il enregistrera l'événement même dans les cas où l'entité a silencieusement ignoré la demande.
L'événement n'est alors plus un reflet fidèle de ce qui s'est passé — c'est une déclaration
d'intention, pas un constat. Cette dissonance crée des listeners déclenchés à tort, des emails
envoyés en double, des compteurs faux.
Règle simple : si l'entité est la seule à savoir qu'une transition s'est produite, elle est la seule à avoir le droit d'enregistrer l'événement correspondant.
Piège 2 : dispatcher les événements avant le flush
L'autre erreur classique, c'est de dispatcher les événements trop tôt — avant que Doctrine ait confirmé que les changements sont bien en base. L'intuition peut sembler raisonnable : "je sais que le cours va être publié, alors j'informe les listeners maintenant." Le problème, c'est que la base de données peut encore rejeter la transaction.
❌ Dispatch avant le flush
// Dans un subscriber Doctrine naïf // postPersist : appelé PENDANT le flush, // avant que la transaction soit terminée public function postPersist(PostPersistEventArgs $args): void { $entity = $args->getObject(); if (!method_exists($entity, 'releaseEvents')) { return; } // ⚠ On dispatche ici — mais la transaction n'est pas committée foreach ($entity->releaseEvents() as $event) { $this->dispatcher->dispatch($event); } }
✅ Dispatch dans postFlush, après commit
// postPersist : on met l'entité de côté, c'est tout public function postPersist(PostPersistEventArgs $args): void { $this->entities[] = $args->getObject(); } // postFlush : la transaction est commitée — on dispatche public function postFlush(PostFlushEventArgs $args): void { $entities = $this->entities; $this->entities = []; foreach ($entities as $entity) { if (!method_exists($entity, 'releaseEvents')) { continue; } // Transaction confirmée : les effets secondaires sont sûrs foreach ($entity->releaseEvents() as $event) { $this->dispatcher->dispatch($event); } } }
Le scénario catastrophe de la colonne de gauche : le listener envoie un email de confirmation
à l'abonné — et une seconde plus tard, Doctrine rollback la transaction parce qu'une contrainte
d'unicité a éclaté, ou qu'une autre partie du flush a levé une exception. L'email est parti.
L'état en base n'a pas changé. Tu as un abonné notifié d'une publication qui n'a jamais eu lieu.
postFlush ne se déclenche pas si le flush échoue — c'est exactement la garantie
qu'on cherche.
Piège 3 : mettre de la logique métier dans le listener
Le troisième piège est plus insidieux parce qu'il arrive progressivement. Le listener commence simple, puis on y ajoute une condition, puis une autre, et sans s'en rendre compte on finit avec de la logique métier dans une classe d'infrastructure.
❌ Logique métier dans le listener
// src/Infrastructure/Listener/NotifyCoursePublishedListener.php #[AsEventListener] public function __invoke(CourseWasPublished $event): void { $course = $event->course; // De la logique métier qui se glisse dans l'infra if ($course->getLevel() === CourseLevel::Advanced) { $subscribers = $this->subscriptions->findExpertSubscribers(); } else { $subscribers = $this->subscriptions->findAll(); } if (!$course->isFeatured() && count($subscribers) > 100) { // Une règle de priorité codée en dur dans un listener $this->mailer->sendBulk($subscribers, $course); } else { $this->mailer->sendSingle($subscribers[0] ?? null, $course); } }
✅ Listener fin qui délègue à un service
// src/Infrastructure/Listener/NotifyCoursePublishedListener.php #[AsEventListener] public function __invoke(CourseWasPublished $event): void { // Le listener orchestre, il ne décide pas // Toute la logique vit dans le service métier ($this->notifyCourseSubscribers)(new NotifyCourseSubscribersInput( courseId: $event->course->getId(), )); }
La logique de sélection des destinataires, les règles de priorité, les conditions
d'envoi — tout cela vit dans NotifyCourseSubscribers, une classe
testable en isolation, sans Symfony, sans Doctrine.
Un listener doit être presque transparent : il reçoit un événement, il fait une seule chose, il délègue. Si tu commences à lire les propriétés de l'entité pour décider comment réagir, tu es en train de placer de la logique métier hors du domaine — là où elle ne peut pas être testée proprement, et là où elle sera impossible à retrouver quand elle bugue en production.
recordEvent() depuis
l'extérieur de l'entité, c'est un signal que la méthode métier concernée est incomplète —
corrige la méthode, pas le code appelant.
Synthèse : ce que le pattern change concrètement
Le flow complet, les bénéfices, les limites — et quand l'adopter.
Tu as vu le problème — l'entité qui dépend d'un service d'infrastructure. Tu as vu la solution — collecter les événements à l'intérieur, les dispatcher après le flush. Tu as vu comment le brancher dans Symfony, et trois pièges concrets à éviter. Il est temps de prendre du recul et de regarder ce que ce pattern change réellement dans un projet.
Le flow complet, en un coup d'œil
Du moment où tu appelles publish() jusqu'au listener qui réagit, voilà le chemin
complet qu'emprunte un Domain Event :
publish()entité
recordEvent()trait interne
flush()Doctrine
postFlushsubscriber
releaseEvents()entité
dispatch()Symfony
#[AsEventListener]listener
Chaque flèche marque une frontière de responsabilité : l'entité enregistre le fait, Doctrine confirme la persistance, le subscriber fait le pont, Symfony achemine. Aucune de ces couches ne mord sur la suivante.
Ce qu'on a gagné
Course::publish() ne reçoit aucun service en paramètre. Elle n'importe rien de
Symfony, rien de Doctrine, rien du mailer. Elle change d'état et enregistre un fait — c'est
sa responsabilité, et uniquement elle. La couche domaine reste autonome, transportable,
réutilisable.
Pour vérifier qu'un cours qui se publie émet bien un événement, tu n'as besoin de rien d'autre que PHP :
$course->publish();
$this->assertTrue($course->hasEvents());
Pas de mock de mailer, pas de container Symfony, pas de base de données. Le test est instantané et ne peut pas être cassé par un changement d'infrastructure.
Ajouter un comportement à la publication d'un cours — un webhook, un journal d'audit, une entrée dans un moteur de recherche — se fait en créant un nouveau listener. L'entité
Course n'est pas modifiée, les listeners existants ne sont pas touchés. Le
code qui réagit à un fait grandit orthogonalement au code qui produit ce fait.
Ce qu'on a perdu
Ce pattern a un coût réel, qu'il vaut mieux nommer honnêtement plutôt que de le minimiser.
- Une indirection supplémentaire. Le chemin d'exécution n'est plus linéaire dans un seul fichier. Pour comprendre ce qui se passe après une publication, il faut suivre la chaîne : entité → trait → subscriber → dispatcher → listeners. Un développeur qui découvre le projet devra apprendre ce mécanisme avant de pouvoir le tracer mentalement.
-
Un flow non linéaire. Quand un comportement ne se déclenche pas ou se
déclenche à tort, la question "qui écoute cet événement ?" ne se répond pas en lisant
un seul fichier. Il faut chercher tous les
#[AsEventListener]du projet, ou inspecter la configuration du container Symfony. Dans un grand projet, ça peut prendre du temps.
Récapitulatif
| Aspect | Approche naïve | Domain Events |
|---|---|---|
| Entité | Dépend de services infra | Pure, aucune dépendance |
| Tests unitaires | Nécessitent des mocks | PHP vanilla suffit |
| Ajout d'un comportement | Modifier le handler ou l'entité | Créer un nouveau listener |
| Cohérence transactionnelle | Effets avant ou pendant commit | Effets après commit confirmé |
| Lisibilité du flow | Linéaire, tout dans un fichier | Distribué — chercher qui écoute |
Ce raisonnement s'inspire d'un article de Udi Dahan, Domain Events – Salvation (2009), qui formalise l'idée en C# avec NServiceBus — les principes s'appliquent naturellement en PHP.