← Tous les cours / DDD : les Domain Events Cours
00 — INTRODUCTION

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.

💡 Le principe : une entité qui change d'état ne devrait pas savoir ce qui se passe ensuite — c'est le rôle des Domain Events.

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 :

01 — LE PROBLÈME

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);
    }
}
Encapsulation du domaine
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 :

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.

🚫 À ne pas faire : une entité qui dépend d'un service d'infrastructure n'est plus une entité de domaine. Elle est devenue un objet hybride — et les objets hybrides sont les plus difficiles à tester, à faire évoluer, et à comprendre.

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.

02 — DOMAIN EVENTS

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.

Domain Event
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;
    }
}
Ubiquitous Language — nommer au passé
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.

💡 Découplage total : si demain tu ajoutes un nouveau comportement à la publication d'un cours (un webhook vers un CRM, une entrée dans un journal d'audit), tu n'as pas à toucher à l'entité Course. Tu crées un nouveau listener. L'entité ignore son existence.
Événement vs Commande
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.

03 — IMPLÉMENTATION PHP

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 :

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.

Recorded Events
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é.

⚠️ Règle d'or : les événements doivent être enregistrés DANS les méthodes métier de l'entité, pas en dehors. Appeler 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.

04 — BRANCHEMENT SYMFONY

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.

postFlush vs postPersist / postUpdate
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);
            }
        }
    }
}
💡 Pourquoi vider $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.
🔧 Pourquoi 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()
postFlush
Doctrine
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é.

Nommer ses méthodes de listener
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.
⚠️ Attention aux modifications Doctrine depuis un listener : si ton listener appelle $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.

05 — ERREURS COURANTES

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.

🗺️ Où enregistrer l'événement ? Une seule réponse valide : dans la méthode métier de l'entité qui effectue la transition d'état. C'est là que l'entité "sait" ce qui vient de se passer, c'est là que les gardes sont évaluées, c'est là que les invariants sont respectés. Jamais dans un handler, jamais dans un service applicatif, jamais dans un subscriber Doctrine. Si tu te retrouves à appeler 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.
⚠️ Quand ne pas utiliser les Domain Events : les Domain Events apportent de la valeur quand une transition d'état a des effets de bord réels — notifications, index, webhooks, audit. Dans un CRUD simple sans réaction en chaîne, ils ajoutent de la complexité sans bénéfice : une entité qui ne fait que changer un champ texte n'a pas besoin d'événements. De même, dans un contexte où une seule classe ou un seul module est concerné par la transition, l'appel direct reste plus lisible et plus direct. Adopter ce pattern par principe, sans qu'il y ait une vraie multiplicité d'observateurs, c'est de l'over-engineering. Utilise-le quand plusieurs parties du système indépendantes doivent réagir à un même fait métier — pas avant.
06 — SYNTHÈSE

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
postFlush
subscriber
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é

Entités pures, sans dépendances infrastructure
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.
Testabilité sans mock
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.
Extensibilité sans toucher l'entité
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.

💡 Quand adopter les Domain Events ? Dès qu'une opération métier doit déclencher plusieurs effets secondaires découplés — notifications, indexation, audit, webhooks — et que ces effets sont portés par des modules indépendants. Si tu as un seul effet, un appel direct reste plus lisible. Si tu en as deux ou plus, portés par des équipes ou des modules différents, les Domain Events deviennent le meilleur outil pour éviter que tout se couple dans un seul handler.

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.