Skip to content

Events & Listeners — EventDispatcher, domain events

TL;DR — Le EventDispatcher est le système nerveux de Symfony : kernel events (KernelEvents::REQUEST, RESPONSE, EXCEPTION, TERMINATE) + tes propres événements métiers. Tu écoutes via listener (callable) ou subscriber (classe qui s'inscrit elle-même). Depuis 5.3, #[AsEventListener] rend la déclaration déclarative et tag-free. Les domain events (DDD) émergent d'un aggregate, sont dispatchés après commit, et peuvent être routés vers Messenger pour devenir async.

🧠 Mental model

                       dispatch(event)


                  ┌─────────────────────┐
                  │   EventDispatcher   │
                  │  (event_name → [    │
                  │     listener#10,    │
                  │     listener#0,     │
                  │     listener#-20]   │
                  └──────────┬──────────┘
                             │ sorted by priority desc
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
         Listener A     Listener B     Subscriber C
         (sync)         (sync)         (multiple events)
              │              │              │
              │   $event->stopPropagation()─┤ (StoppableEventInterface)
              ▼              ▼              ▼
        [side-effect]  [side-effect]  [side-effect]


KERNEL FLOW (HTTP):
  REQUEST → CONTROLLER → CONTROLLER_ARGUMENTS → [controller()] →
  VIEW (if not Response) → RESPONSE → FINISH_REQUEST → TERMINATE
                                          (sync ↑)        (after fastcgi_finish_request)

Analogie : le dispatcher est une radio. Tu broadcast un événement (nom + payload). N'importe qui peut brancher son écouteur sur cette fréquence. La priorité décide qui parle en premier. Sync par défaut : tout se passe dans la même requête, dans le même thread. Pour async, il faut passer la main à Messenger.

🛠️ Code minimal

php
// src/Event/OrderPlacedEvent.php
namespace App\Event;

use App\Entity\Order;
use Symfony\Contracts\EventDispatcher\Event;

final class OrderPlacedEvent extends Event
{
    public function __construct(public readonly Order $order) {}
}
php
// src/EventListener/SendOrderConfirmationListener.php
namespace App\EventListener;

use App\Event\OrderPlacedEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Mailer\MailerInterface;

#[AsEventListener(event: OrderPlacedEvent::class, priority: 10)]
final readonly class SendOrderConfirmationListener
{
    public function __construct(private MailerInterface $mailer) {}

    public function __invoke(OrderPlacedEvent $event): void
    {
        $order = $event->order;
        $this->mailer->send(/* … */);
    }
}
php
// src/EventSubscriber/AuditSubscriber.php — un subscriber pour plusieurs events
namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

final readonly class AuditSubscriber implements EventSubscriberInterface
{
    public function __construct(private LoggerInterface $audit) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::RESPONSE  => ['onResponse', -10],
            KernelEvents::EXCEPTION => ['onException', 0],
        ];
    }

    public function onResponse(ResponseEvent $e): void
    {
        if (!$e->isMainRequest()) return;
        $this->audit->info('http.response', [
            'status' => $e->getResponse()->getStatusCode(),
            'path'   => $e->getRequest()->getPathInfo(),
        ]);
    }

    public function onException(ExceptionEvent $e): void
    {
        $this->audit->error('http.exception', ['ex' => $e->getThrowable()]);
    }
}

Dispatch :

php
public function checkout(EventDispatcherInterface $dispatcher, ...): Response
{
    $order = $this->orderService->place($cart);
    $dispatcher->dispatch(new OrderPlacedEvent($order));
    return $this->redirectToRoute('order_thanks', ['id' => $order->getId()]);
}

Listener vs Subscriber — quand choisir quoi

CritèreListener (#[AsEventListener])Subscriber (EventSubscriberInterface)
Nombre d'events1 par classe (idéal)N par classe
DéclarationAttribut (déclaratif)Méthode statique getSubscribedEvents()
DécouverteAutoconfig + tagAutoconfig + interface
LisibilitéExcellente (Single Responsibility)OK mais grossit vite
RefactorRenomme la classe, c'est toutRisque d'oublier de retirer un event
Choix par défaut✅ Pour 1 eventPour des concerns transverses (audit, security)

Anatomie d'un kernel event

php
// src/EventSubscriber/MaintenanceModeSubscriber.php
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;

final class MaintenanceModeSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        // Priority 2048 → before Router (32) and Security (8)
        return [KernelEvents::REQUEST => ['onRequest', 2048]];
    }

    public function onRequest(RequestEvent $e): void
    {
        if (!$e->isMainRequest()) return;
        if (!file_exists('/tmp/maintenance.flag')) return;

        // Short-circuit the kernel: set a Response, no controller runs
        $e->setResponse(new Response('Maintenance', 503, [
            'Retry-After' => '300',
        ]));
        $e->stopPropagation();
    }
}

Quand tu setResponse() sur RequestEvent, Symfony skip le router et le controller et passe directement à RESPONSETERMINATE. Pratique pour maintenance, rate limiting global, feature flags.

🎯 Patterns courants

  1. Domain events on aggregate — l'entité accumule des events ($this->recordEvent(new OrderPlaced(...))), puis un Doctrine postFlush listener les dispatche après commit DB. Garantit qu'on n'émet jamais un event pour un état non-persisté.
  2. Event → Messenger — pour rendre un listener async, fais que le listener dispatche un Message au lieu de bosser direct. Pattern propre, testable.
  3. Listener-as-controller-prepkernel.controller_arguments listener qui injecte un User resolvé d'un attribute custom. Plus propre qu'un ArgumentValueResolver pour des cas simples.
  4. Hooks sur framework eventskernel.terminate pour les tâches qu'on veut faire après envoi de la response (logs lourds, warm-up de cache). Combine avec fastcgi_finish_request().
  5. Stopping propagation — un listener prioritaire peut court-circuiter les suivants via $event->stopPropagation(). À utiliser pour des kill-switches (ex : maintenance mode listener qui short-circuite la requête).
  6. Event sourcing light — stocke chaque domain event en table events_log via un listener, sans full ES, pour avoir un audit trail.

🔄 Versions

  • 5.4 : EventSubscriberInterface standard. Attribut #[AsEventListener] introduit en 5.3, signature : event, method, priority, dispatcher. PsrEventDispatcherInterface compat PSR-14.
  • 6.4 : #[AsEventListener] peut être posé sur la classe sans method si la classe a un __invoke. Auto-resolved event class via le typehint du paramètre. EventDispatcherInterface enrichi.
  • 7.x : nettoyage de getSubscribedEvents (toujours là, mais l'attribut est promu). Les attributs deviennent la méthode canonique. Meilleure inférence de types.

Note : #[AsMessageHandler] est l'analogue côté Messenger — mêmes principes mais pour le bus, pas le dispatcher.

⚠️ Pitfalls

  1. Fat event handlers — un OrderPlacedListener qui fait sendEmail() + updateAnalytics() + notifySlack() + regenerateSitemap(). Split en N listeners mono-responsabilité. Sinon ton listener devient le god-object du domaine.
  2. Sync = blocking — un listener qui appelle une API externe rend ta requête HTTP lente. Tout listener I/O lourd doit dispatcher un message async.
  3. Exception swallowed — par défaut, une exception dans un listener interrompt le dispatch et remonte. Si tu veux du best-effort, wrap dans try/catch dans le listener lui-même.
  4. Order dependency — listener A modifie l'event, listener B lit. Si priorités égales, ordre = ordre de déclaration. Toujours expliciter les priorités pour les chaînes.
  5. Events dispatchés avant commitdispatch(new OrderPlaced) puis flush() échoue → email envoyé pour une commande qui n'existe pas. Solution : dispatch dans postFlush Doctrine, ou collecter sur l'aggregate et flusher dans un seul "release events" après commit.
  6. Listener sur kernel.request avant routingpriority > 32 t'attire avant le router, donc pas de _route. Lis Built-in Symfony events priorities.
  7. Mémoire dans long-running workers — si tu utilises un dispatcher dans Messenger, les listeners peuvent retenir des refs. Réinjecte des services scoped.
  8. Couplage temporel caché — un domain event écouté par 12 listeners devient un invisible coupling. Documente le contrat de l'event (champs immutables, sémantique "le truc s'est passé").

🧪 Testing

php
use Symfony\Component\EventDispatcher\EventDispatcher;

public function testListenerSendsEmail(): void
{
    $mailer = $this->createMock(MailerInterface::class);
    $mailer->expects($this->once())->method('send');

    $dispatcher = new EventDispatcher();
    $dispatcher->addListener(
        OrderPlacedEvent::class,
        new SendOrderConfirmationListener($mailer)
    );

    $dispatcher->dispatch(new OrderPlacedEvent($order));
}
php
// Functional: track dispatched events in WebTestCase
use Symfony\Component\EventDispatcher\Debug\TraceableEventDispatcher;

public function testCheckoutDispatchesOrderPlaced(): void
{
    $client = static::createClient();
    /** @var TraceableEventDispatcher $dispatcher */
    $dispatcher = self::getContainer()->get('debug.event_dispatcher');

    $client->request('POST', '/checkout', [...]);

    $called = array_map(
        fn(array $l) => $l['event'],
        $dispatcher->getCalledListeners()
    );
    $this->assertContains(OrderPlacedEvent::class, $called);
}

Tester un kernel listener : KernelTestCase, simule un RequestEvent via new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST).

🎬 Cas d'usage concrets

Scénario 1 — Audit log event-driven pour conformité bancaire

La néobanque doit conserver une trace immuable de toute action sensible (création de compte, virement, changement d'IBAN, modification de bénéficiaire, ajout de SCA device) pendant 10 ans pour répondre aux exigences ACPR. L'équipe a implémenté un système d'audit event-driven : chaque agrégat domaine émet des événements typés (CompteCree, VirementExecute, BeneficiaireAjoute) via EventDispatcherInterface après commit Doctrine. Un listener AuditLogListener écoute toute classe implémentant AuditableEvent (interface marqueur) et écrit une ligne dans une table append-only audit_log partitionnée par mois, avec hash chaîné (chaque entrée contient le SHA-256 de la précédente) garantissant la détection de tout effacement. Le listener est désynchronisé sur Messenger pour ne pas pénaliser la transaction principale, mais l'événement source est d'abord persisté dans l'outbox pour assurer la livraison. Un second listener MetricsAuditListener incrémente des compteurs Prometheus permettant des alertes en cas de pic anormal (par exemple 100 changements d'IBAN/heure → alerte fraude).

Scénario 2 — Panier e-commerce avec listeners découplés

La marketplace de mode déclenche un événement LigneAjouteePanier à chaque ajout d'article. Plusieurs listeners y réagissent indépendamment : RecalculerTotalListener met à jour le total avec frais de port et codes promo (synchrone), MettreAJourRecommandationsListener envoie les données au moteur ML pour rafraîchir les suggestions (async via Messenger), VerifierStockTemporsListener réserve l'article pendant 15 minutes en Redis avec TTL, TrackerAnalyticsListener envoie l'événement vers Mixpanel et le pixel Meta. Cette architecture permet à chaque équipe (catalogue, marketing, data, logistique) d'ajouter ses listeners sans toucher au code du panier, garantissant une faible couplage et une vélocité élevée. Les priorités de listener sont explicitement documentées : RecalculerTotal à 100 (avant tout le reste), les autres à 0. Les tests unitaires isolent chaque listener avec un dispatcher mock, les tests d'intégration valident la chaîne complète.

Scénario 3 — Onboarding RH avec chaîne d'événements automatisée

Le SaaS RH déclenche l'événement SalarieEmbauche lorsqu'un nouveau collaborateur est créé dans le système. Quinze listeners distincts traitent l'événement de façon coordonnée : création du compte LDAP, envoi d'un mail de bienvenue avec lien de paramétrage de mot de passe, déclenchement de la commande badges (intégration API Heyliot), planification d'un rendez-vous d'accueil avec le manager (Google Calendar), assignation des formations obligatoires (module e-learning), provisioning Slack, ajout au plan de prévoyance, génération du contrat (modèle DocuSign). Chaque listener est asynchrone (Messenger) et indépendant, ce qui permet à un échec ponctuel (Slack indisponible) de ne pas bloquer le reste de l'onboarding. Un listener spécial OnboardingProgressListener agrège les *Termine enfants et marque l'onboarding global comme terminé quand toutes les étapes sont OK. L'équipe RH peut activer/désactiver chaque listener par tenant via flags Unleash, par exemple pour les clients qui n'utilisent pas Slack.

🛠️ Exemple end-to-end

Use case : audit log bancaire event-driven avec hash chaîné, listener déclenché après commit Doctrine, dispatché asynchrone via Messenger.

php
<?php
// src/Domain/Event/AuditableEvent.php
declare(strict_types=1);

namespace App\Domain\Event;

interface AuditableEvent
{
    public function getActorId(): string;
    public function getEntityId(): string;
    public function getEntityType(): string;
    public function getOccurredAt(): \DateTimeImmutable;
    public function getPayload(): array;
}

// src/Domain/Compte/Event/VirementExecute.php
namespace App\Domain\Compte\Event;

use App\Domain\Event\AuditableEvent;

final readonly class VirementExecute implements AuditableEvent
{
    public function __construct(
        public string $virementId,
        public string $compteSourceId,
        public string $compteDestinationId,
        public int $montantCents,
        public string $actorId,
        public \DateTimeImmutable $occurredAt,
    ) {}

    public function getActorId(): string { return $this->actorId; }
    public function getEntityId(): string { return $this->virementId; }
    public function getEntityType(): string { return 'virement'; }
    public function getOccurredAt(): \DateTimeImmutable { return $this->occurredAt; }
    public function getPayload(): array
    {
        return [
            'source' => $this->compteSourceId,
            'destination' => $this->compteDestinationId,
            'montant_cents' => $this->montantCents,
        ];
    }
}

// src/Application/Audit/Listener/AuditLogListener.php
namespace App\Application\Audit\Listener;

use App\Application\Audit\Message\PersistAuditEntry;
use App\Domain\Event\AuditableEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\MessageBusInterface;

final readonly class AuditLogListener
{
    public function __construct(private MessageBusInterface $bus) {}

    #[AsEventListener]
    public function onAuditableEvent(AuditableEvent $event): void
    {
        $this->bus->dispatch(new PersistAuditEntry(
            actorId: $event->getActorId(),
            entityType: $event->getEntityType(),
            entityId: $event->getEntityId(),
            occurredAt: $event->getOccurredAt(),
            payload: $event->getPayload(),
        ));
    }
}

// src/Application/Audit/Handler/PersistAuditEntryHandler.php
namespace App\Application\Audit\Handler;

use App\Application\Audit\Message\PersistAuditEntry;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Uid\Uuid;

#[AsMessageHandler]
final readonly class PersistAuditEntryHandler
{
    public function __construct(private Connection $db) {}

    public function __invoke(PersistAuditEntry $msg): void
    {
        $this->db->transactional(function (Connection $c) use ($msg): void {
            $previous = $c->fetchOne('SELECT hash FROM audit_log ORDER BY created_at DESC LIMIT 1 FOR UPDATE') ?: str_repeat('0', 64);
            $payload = json_encode($msg->payload, JSON_THROW_ON_ERROR);
            $line = $previous . '|' . $msg->actorId . '|' . $msg->entityType . '|' . $msg->entityId . '|' . $msg->occurredAt->format(\DATE_RFC3339_EXTENDED) . '|' . $payload;
            $hash = hash('sha256', $line);
            $c->insert('audit_log', [
                'id' => Uuid::v7()->toRfc4122(),
                'actor_id' => $msg->actorId,
                'entity_type' => $msg->entityType,
                'entity_id' => $msg->entityId,
                'payload' => $payload,
                'previous_hash' => $previous,
                'hash' => $hash,
                'created_at' => $msg->occurredAt->format('Y-m-d H:i:s.u'),
            ]);
        });
    }
}

🔁 Quand utiliser / éviter

Utiliser :

  • Réagir à un changement d'état du domaine ("après que X soit arrivé, faire Y").
  • Side-effects optionnels qu'on peut ajouter sans toucher au code central.
  • Cross-cutting concerns : logging, audit, métriques.
  • Découpage modulaire (un bundle externe peut écouter tes events).
  • Decoupler controller des side-effects.

Éviter :

  • Pour des commandes / requêtes (utilise un service / command bus).
  • Quand il faut un résultat de retour ("dispatch & forget" = ✓, "dispatch & get result" = ✗ → service direct).
  • Trop de listeners cachés → chains invisibles. Préférer un service explicite quand il n'y a qu'un seul consumer.
  • Chaîne event → event → event → event = spaghetti. Préférer un orchestrateur explicite (Workflow, saga, service).

🧬 Domain events vs framework events — décortiqué

Un framework event (kernel.request, workflow.entered) est une hook technique fournie par Symfony. Tu y branches du code transverse.

Un domain event (OrderPlaced, InvoiceIssued) est un concept métier qui exprime "tel fait s'est produit dans le domaine". Ce sont des objets immuables (final readonly) avec un nom au passé. Ils vivent dans src/Domain/Event/, pas dans src/EventListener/.

php
// src/Domain/Event/OrderPlaced.php
namespace App\Domain\Event;

final readonly class OrderPlaced
{
    public function __construct(
        public string $orderId,
        public string $customerId,
        public int $totalCents,
        public \DateTimeImmutable $placedAt,
    ) {}
}

Aggregate enregistre, ne dispatche pas :

php
// src/Domain/RecordsEvents.php — interface marqueur consommée par le Doctrine listener
namespace App\Domain;

interface RecordsEvents
{
    /** @return list<object> Vide la file d'events et la renvoie. */
    public function releaseEvents(): array;
}

// src/Domain/Order.php
namespace App\Domain;

use App\Domain\Event\OrderPlaced;

class Order implements RecordsEvents
{
    /** @var list<object> */
    private array $recordedEvents = [];

    public function place(): void
    {
        // … business invariants …
        $this->status = 'placed';
        $this->recordedEvents[] = new OrderPlaced(
            $this->id, $this->customerId, $this->totalCents, new \DateTimeImmutable()
        );
    }

    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }
}

Un Doctrine postFlush listener se charge ensuite de dispatcher après commit (ou de les pousser dans Messenger si tu veux du async / cross-bounded-context). Voici le listener complet — c'est la pièce que 90 % des tutoriels mentionnent sans jamais montrer :

php
// src/Infrastructure/Doctrine/DomainEventDispatchListener.php
namespace App\Infrastructure\Doctrine;

use App\Domain\RecordsEvents;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class DomainEventDispatchListener
{
    /** @var list<object> */
    private array $pending = [];

    public function __construct(private readonly EventDispatcherInterface $dispatcher) {}

    // onFlush : on COLLECTE pendant que l'UoW connaît encore les entités modifiées.
    public function onFlush(OnFlushEventArgs $args): void
    {
        $uow = $args->getObjectManager()->getUnitOfWork();
        foreach ([...$uow->getScheduledEntityInsertions(), ...$uow->getScheduledEntityUpdates()] as $entity) {
            if ($entity instanceof RecordsEvents) {
                $this->pending = [...$this->pending, ...$entity->releaseEvents()];
            }
        }
    }

    // postFlush : la transaction est COMMITÉE → on dispatche pour de vrai.
    public function postFlush(PostFlushEventArgs $args): void
    {
        $events = $this->pending;
        $this->pending = []; // reset AVANT dispatch : un listener qui re-flush ne doit pas boucler
        foreach ($events as $event) {
            $this->dispatcher->dispatch($event);
        }
    }
}

Pourquoi onFlush + postFlush et pas juste postFlush ? Dans postFlush, l'UnitOfWork est déjà nettoyé : getScheduledEntityInsertions() renvoie []. Il faut donc collecter en onFlush (entités encore connues, transaction pas commitée) et dispatcher en postFlush (commit fait). C'est le couple canonique pour « émettre après commit ».

Les 3 stratégies de livraison d'un domain event — tableau de décision

Le vrai sujet senior n'est pas « comment dispatcher » mais quelle garantie de livraison. Trois stratégies, du plus simple au plus robuste :

StratégieGarantieQuand le side-effect peut se perdreCoûtVerdict staff
Dispatch sync dans le handlerAtomique SI le listener écrit dans la même transaction DBListener fait du I/O externe (mail, HTTP) → commit OK mais effet perdu sur crashNulOK pour effets purement DB (projection, compteur)
postFlush → dispatch sync« At-most-once » : effet après commit, mais perdu si le worker crash entre commit et effetCrash entre COMMIT et mailer->send()FaibleAcceptable pour effets idempotents/non-critiques
Outbox transactionnel« At-least-once » : effet garanti, jamais avant commitJamais (au prix de doublons → handlers idempotents)Table outbox + relayObligatoire pour argent, conformité, cross-service

Outbox en une phrase : dans la même transaction que le changement métier, tu INSERT une ligne dans outbox (l'event sérialisé). Un relais (worker Messenger doctrine transport, ou poll) lit l'outbox et publie. Comme l'INSERT outbox et le COMMIT métier sont atomiques, soit les deux réussissent, soit aucun — plus de « mail envoyé pour une commande inexistante », ni l'inverse. Le prix : la livraison devient at-least-once, donc tes handlers doivent être idempotents (clé d'idempotence sur event_id). C'est exactement ce que fait le scénario bancaire plus bas avec son audit_outbox.

       ┌─ TRANSACTION ────────────────────────┐
       │  UPDATE orders SET status='placed'   │
       │  INSERT INTO outbox (event, ...)      │  ← atomique
       └─────────────── COMMIT ───────────────┘

                          ▼  (séparément, après commit)
              relay → MessageBus → handler (idempotent)

🧭 Comment un staff engineer raisonne là-dessus

Quand on lui pose « on met un event ou pas ? », le réflexe staff n'est pas technique, il est directionnel :

  • Sens de dépendance. Un event inverse la dépendance : le producteur ne connaît pas ses consommateurs. C'est génial pour l'extensibilité, toxique pour la lisibilité quand tu débugues un flux que tu ne peux pas suivre en lisant le code linéairement. Règle : event quand il y a 0..N consommateurs inconnus a priori (audit, plugins, bounded contexts) ; appel de service direct quand il y a exactement 1 consommateur connu. Un event avec un seul listener est un appel de méthode déguisé, en plus lent et plus dur à tracer.
  • Garantie de livraison avant tout. Avant d'écrire le listener, il décide : cet effet peut-il se perdre ? Si non (argent, conformité) → outbox, point. Le code du listener vient après ce choix d'architecture, pas avant.
  • Frontière de transaction. « Cet event est-il dispatché dans, ou après, la transaction DB ? » détermine s'il faut le couple onFlush/postFlush. Confondre les deux est la source nº 1 de bugs « effet sans état » ou « état sans effet ».
  • Couplage temporel invisible. Un event écouté par 12 listeners est un contrat implicite. Il le rend explicite : event final readonly, nommé au passé, champs documentés, et un test qui assert la liste des listeners abonnés (régression si quelqu'un en branche un de plus sur un flux critique sans revue).
  • Observabilité. En prod, un dispatch async qui échoue est silencieux. Il instrumente : compteur de dispatch, compteur d'échec par listener, et trace OpenTelemetry propagée du producteur au handler Messenger (sinon le span casse à la frontière du bus).

🏋️ Exercices

1. Le couple onFlush/postFlush (implémentation)

Objectif : émettre OrderPlaced après commit sans jamais l'émettre si le flush échoue. Implémente l'aggregate Order implements RecordsEvents, le DomainEventDispatchListener, et un test : ouvre une transaction, place() + flush() qui throw (viole une contrainte unique) → aucun event ne doit être dispatché ; flush qui réussit → exactement un. Indice/Solution : collecte en onFlush via UnitOfWork::getScheduledEntityInsertions(), dispatche en postFlush. Pour le test du rollback, mocke une UniqueConstraintViolationException et vérifie via un TraceableEventDispatcher que getCalledListeners() est vide.

2. Maintenance kill-switch prioritaire (production-grade)

Objectif : un kernel.request listener qui court-circuite TOUT (sauf une allowlist d'IP admin) en mode maintenance, sans casser le profiler ni les assets. Indice/Solution : priorité > 256 mais filtre $e->isMainRequest(), lis le flag depuis un CacheInterface (pas un file_exists qui hit le disque à chaque requête), allowlist d'IP via $request->getClientIp(), setResponse(503) + Retry-After + stopPropagation(). Bonus : expose le flag via une commande console pour activer/désactiver sans déploiement.

3. Outbox transactionnel (production-grade)

Objectif : garantir « at-least-once » pour un VirementExecute : jamais d'effet avant commit, jamais d'effet perdu après. Écris la table outbox(id, event_class, payload, created_at, dispatched_at), un listener qui INSERT dans la même transaction que le virement, et un relais (command worker) qui poll les lignes dispatched_at IS NULL, publie sur le bus, puis marque dispatched_at. Indice/Solution : l'atomicité vient du fait que l'INSERT outbox et l'UPDATE compte partagent la connexion Doctrine. Le relais doit SELECT ... FOR UPDATE SKIP LOCKED pour scaler horizontalement. Handler idempotent via clé d'unicité sur event_id. Compare avec le transport Messenger doctrine natif : tu réimplémentes 80 % de ce qu'il fait.

4. Casse puis répare : la boucle de re-flush (break-then-fix)

Objectif : reproduire puis corriger un dispatch qui boucle à l'infini. Pars du DomainEventDispatchListener mais déplace le reset de $this->pending après la boucle de dispatch. Branche un listener qui, en réaction à OrderPlaced, modifie une entité et re-flush(). Observe la récursion / le double-dispatch. Indice/Solution : le flush() du listener re-déclenche postFlush, qui redispatche $this->pending (pas encore vidé) → boucle. Fix : vide $this->pending avant la boucle (snapshot local), comme dans le code de référence. Discute pourquoi un listener ne devrait de toute façon pas re-flusher de manière synchrone (préférer Messenger).

5. Chaîne ordonnée mutator → reader (break-then-fix)

Objectif : un listener ApplyDiscount mute l'event, un listener ChargePayment lit le total — sans priorité explicite, le paiement passe au prix plein de façon non déterministe. Indice/Solution : reproduis avec deux listeners à priorité 0 (ordre = ordre de déclaration/découverte, fragile au refactor et à l'ordre de scan du container). Fix : priorité explicite (ApplyDiscount à 100, ChargePayment à 0), OU — mieux — rends l'event immuable et fais que ChargePayment recalcule depuis l'aggregate plutôt que de lire un event mutable. Conclus : un event qui se fait muter en chaîne est un anti-pattern ; préfère un objet de contexte explicite ou un pipeline.

6. Propagation de trace au passage du bus (architecte)

Objectif : une trace OpenTelemetry continue du controller jusqu'au handler async, alors que Messenger fait perdre le span context. Indice/Solution : injecte le traceparent W3C dans un stamp Messenger custom au dispatch, restaure-le dans un middleware côté worker avant l'exécution du handler. Vérifie dans Jaeger/Tempo que le span du handler est bien enfant du span HTTP. C'est le genre de détail qui distingue « j'ai branché un event » de « mon système est observable en prod ».

🎤 En entretien

Q : Différence entre listener et subscriber, et lequel par défaut en 2025 ? R : Même mécanisme (le subscriber s'auto-enregistre via getSubscribedEvents(), le listener est déclaré par #[AsEventListener] ou tag). Par défaut : listener mono-event invocable (__invoke) pour la Single Responsibility ; subscriber seulement pour un concern transverse qui couvre plusieurs events (audit, security) où regrouper a du sens.

Q : Je dispatche OrderPlaced puis le flush() échoue — l'email part quand même. Comment tu corriges ? R : Le bug est la frontière de transaction. On n'émet jamais avant commit : l'aggregate enregistre l'event (recordEvent), un Doctrine listener collecte en onFlush et dispatche en postFlush. Si l'effet ne doit jamais se perdre (argent), on passe à un outbox transactionnel (INSERT dans la même transaction + relais) avec handlers idempotents — la garantie devient at-least-once.

Q : Quand un event est-il un mauvais choix ? R : Quand il y a exactement un consommateur connu (c'est un appel de service déguisé, plus lent et plus dur à tracer), quand tu as besoin d'une valeur de retour (dispatch & forget oui, dispatch & get result non), et quand tu chaînes event→event→event (spaghetti invisible → préfère un orchestrateur explicite : Workflow, saga, command bus).

Q : Sync vs async pour un listener — comment tu tranches, et quel est le piège async ? R : Tranche sur le chemin critique : un effet I/O (mail, HTTP, ML) ne doit jamais bloquer la requête → le listener dispatche un Message au lieu de faire le travail. Le piège : l'async est at-least-once et silencieux en cas d'échec → handlers idempotents obligatoires, dead-letter/retry configuré, et observabilité (compteur d'échec + trace propagée à travers le bus, sinon le span casse à la frontière Messenger).

🔗 Liens

Bibliothèque tech perso — Achref