Skip to content

Event Dispatcher

Vier Klassen, null Magie: synchrones Event-Dispatching für Domain-Driven Design.

Einführung

Domain Events sind das Rückgrat einer sauberen DDD-Architektur: "Eine Bestellung wurde aufgegeben", "Ein Passwort wurde geändert", "Ein Lagerbestand wurde reserviert." Damit diese Events zuverlässig ankommen, braucht es einen Dispatcher, aber keinen, der ein halbes Framework mitbringt.

jardisadapter/eventdispatcher ist bewusst minimalistisch. Vier Klassen, vollständig PSR-14 kompatibel, ohne Annotations, ohne Subscriber-Pattern, ohne Auto-Discovery:

  • PSR-14 kompatibelEventDispatcherInterface, ListenerProviderInterface und StoppableEventInterface vollständig implementiert
  • Prioritäts-Ordering — höhere Priorität = frühere Ausführung. Direkte und Wildcard-Listener werden gemeinsam sortiert
  • Type-Hierarchy-Matching — ein Listener auf einem Interface fängt alle Events, die es implementieren
  • Stoppable Events — jedes Event kann die Listener-Kette abbrechen
  • EventCollector — das DDD-Pattern: Events in der Domain sammeln, im Application Layer dispatchen
  • Explizite Registrierung — kein Classpath-Scanning, keine Reflection, keine impliziten Abhängigkeiten

Installation

bash
composer require jardisadapter/eventdispatcher

GitHub: jardisAdapter/eventdispatcher

Grundlegende Nutzung

Listener registrieren und Events dispatchen

php
use JardisAdapter\EventDispatcher\EventDispatcher;
use JardisAdapter\EventDispatcher\ListenerProvider;

$provider = new ListenerProvider();

$provider->listen(OrderCreated::class, function (OrderCreated $event): void {
    // E-Mail-Bestätigung senden
});

$provider->listen(OrderCreated::class, function (OrderCreated $event): void {
    // Lagerbestand aktualisieren
});

$dispatcher = new EventDispatcher($provider);

// Event dispatchen — alle registrierten Listener werden aufgerufen
$dispatcher->dispatch(new OrderCreated($orderId));

Eigene Domain Events definieren

Events sind einfache PHP-Objekte. Die optionale Basisklasse Event fügt Stoppable-Funktionalität hinzu:

php
use JardisAdapter\EventDispatcher\Event;

final class OrderCreated extends Event
{
    public function __construct(
        public readonly string $orderId,
        public readonly float $totalAmount,
    ) {}
}

Ohne Basisklasse funktioniert jedes object als Event, dann allerdings ohne stopPropagation().

Prioritäten

Listener können mit Prioritäten versehen werden. Höhere Zahl = frühere Ausführung:

php
$provider->listen(OrderCreated::class, $sendConfirmation, priority: 10);  // zuerst
$provider->listen(OrderCreated::class, $updateInventory,  priority: 5);   // danach
$provider->listen(OrderCreated::class, $logEvent);                        // zuletzt (0)

Negative Prioritäten sind erlaubt: nützlich für "Aufräum"-Listener, die immer am Ende laufen sollen.

Type-Hierarchy-Matching

Ein Listener, der auf ein Interface oder eine Basisklasse registriert ist, fängt alle Events, die diesen Typ implementieren oder erweitern:

php
interface PaymentEventInterface {}

final class PaymentReceived extends Event implements PaymentEventInterface
{
    public function __construct(public readonly string $paymentId) {}
}

final class PaymentFailed extends Event implements PaymentEventInterface
{
    public function __construct(public readonly string $paymentId, public readonly string $reason) {}
}

// Fängt BEIDE Events — PaymentReceived und PaymentFailed
$provider->listen(PaymentEventInterface::class, function (PaymentEventInterface $event): void {
    // Audit-Log für alle Payment-Events
});

// Fängt JEDES Event, das Event erweitert
$provider->listen(Event::class, function (Event $event): void {
    // Globaler Logger
});

Direkte und Wildcard-Listener werden gemeinsam nach Priorität sortiert. Ein Interface-Listener mit Priorität 10 wird vor einem direkten Listener mit Priorität 5 aufgerufen.

Stoppable Events

Ein Listener kann die Verarbeitung stoppen. Nachfolgende Listener werden nicht mehr aufgerufen:

php
$provider->listen(OrderCreated::class, function (OrderCreated $event): void {
    if ($event->totalAmount > 10000) {
        // Fraud-Check blockiert die Bestellung
        $event->stopPropagation();
    }
}, priority: 100);  // Hohe Priorität: läuft als erstes

$provider->listen(OrderCreated::class, function (OrderCreated $event): void {
    // Wird NICHT aufgerufen, wenn Propagation gestoppt wurde
    sendConfirmationEmail($event);
}, priority: 0);

Bereits gestoppte Events

Wird ein Event dispatched, das bereits gestoppt ist (isPropagationStopped() === true), werden keine Listener aufgerufen.

EventCollector — Deferred Dispatch

In einer DDD-Architektur sollen Domain-Objekte Events aufzeichnen, ohne eine Abhängigkeit zum Dispatcher zu haben. Der EventCollector löst das:

php
use JardisAdapter\EventDispatcher\EventCollector;

// Domain Layer — kein Dispatcher nötig
$collector = new EventCollector();
$collector->record(new OrderCreated($orderId));
$collector->record(new InventoryReserved($itemId));

// Application Layer — nach der Use-Case-Logik
$collector->dispatchAll($dispatcher);

Sicheres Dispatch-Verhalten

dispatchAll() leert die interne Liste vor dem Dispatching. Events, die während des Dispatchings durch Listener aufgezeichnet werden, landen im Collector, aber nicht im aktuellen dispatchAll()-Durchlauf. So werden kaskadierende Events nicht endlos verarbeitet.

php
$collector->record($event1);
$collector->record($event2);

// Dispatcht $event1 und $event2. Danach ist der Collector leer.
$collector->dispatchAll($dispatcher);

// Utility-Methoden
$collector->count();     // Anzahl gesammelter Events
$collector->events();    // Events inspizieren ohne zu dispatchen
$collector->clear();     // Events verwerfen ohne zu dispatchen

Fluent API

php
$collector
    ->record(new OrderCreated($orderId))
    ->record(new PaymentReceived($paymentId))
    ->record(new InventoryReserved($itemId))
    ->dispatchAll($dispatcher);

Listener entfernen

php
$listener = function (OrderCreated $event): void { /* ... */ };

$provider->listen(OrderCreated::class, $listener);
$provider->remove(OrderCreated::class, $listener);  // Sicher: No-Op wenn nicht registriert

Fehlerbehandlung

Das Package definiert keine eigenen Exceptions. Fehler in Listenern propagieren unverändert zum Aufrufer:

SituationVerhalten
Listener wirft ExceptionPropagiert unverändert zum Caller
Kein Listener registriertEvent wird stillschweigend ignoriert
Event bereits gestopptKeine Listener werden aufgerufen

Architektur

Das Package folgt dem Closure-Orchestrator-Pattern in seiner minimalistischsten Form, vier Klassen, klare Verantwortlichkeiten:

EventDispatcher                    ← Orchestrator (PSR-14)
├── ListenerProvider               ← Registry + Matcher
│   └── [callable, priority][]     ← Listener-Registrierung
├── Event                          ← Abstrakte Basis (optional)
└── EventCollector                 ← Deferred Dispatch (DDD)

Verzeichnisstruktur

src/
├── EventDispatcher.php      ← Orchestrator (PSR-14 EventDispatcherInterface)
├── ListenerProvider.php     ← Registry (PSR-14 ListenerProviderInterface)
├── Event.php                ← Abstrakte Basis (StoppableEventInterface)
└── EventCollector.php       ← Deferred Dispatch Value Object

Design-Entscheidungen

EntscheidungBegründung
Kein EventSubscriberInterfaceStatische Subscriber verletzen das Prinzip expliziter Abhängigkeiten
Kein Async DispatchSynchron by Design. Für Cross-Process-Events: jardisadapter/messaging
Kein Event Store/SourcingPersistenz ist Infrastruktur-Verantwortung
Kein Listener DiscoveryKein Classpath-Scanning, keine Reflection. Registrierung ist explizit

API-Referenz

EventDispatcher

MethodeSignaturBeschreibung
__construct__construct(ListenerProviderInterface $provider)PSR-14 Provider injizieren
dispatchdispatch(object $event): objectEvent dispatchen, Listener aufrufen

ListenerProvider

MethodeSignaturBeschreibung
listenlisten(string $eventClass, callable $listener, int $priority = 0): voidListener registrieren
removeremove(string $eventClass, callable $listener): voidListener entfernen
getListenersForEventgetListenersForEvent(object $event): iterableListener für Event abrufen (sortiert)

Event

MethodeSignaturBeschreibung
isPropagationStoppedisPropagationStopped(): boolPropagation gestoppt?
stopPropagationstopPropagation(): voidPropagation stoppen

EventCollector

MethodeSignaturBeschreibung
recordrecord(object $event): selfEvent aufzeichnen
dispatchAlldispatchAll(EventDispatcherInterface $dispatcher): selfAlle Events dispatchen + leeren
eventsevents(): arrayGesammelte Events inspizieren
clearclear(): selfEvents verwerfen
countcount(): intAnzahl gesammelter Events

Vollständiges Beispiel

Ein typisches DDD-Setup mit Domain Events, priorisierten Listenern, Type-Hierarchy-Matching und EventCollector:

php
use JardisAdapter\EventDispatcher\Event;
use JardisAdapter\EventDispatcher\EventCollector;
use JardisAdapter\EventDispatcher\EventDispatcher;
use JardisAdapter\EventDispatcher\ListenerProvider;

// Domain Events definieren
final class OrderPlaced extends Event
{
    public function __construct(
        public readonly string $orderId,
        public readonly float $total,
    ) {}
}

final class PaymentProcessed extends Event
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $transactionId,
    ) {}
}

// Listener Provider konfigurieren
$provider = new ListenerProvider();

// Globaler Audit-Logger für ALLE Events (niedrige Priorität)
$provider->listen(Event::class, function (Event $event): void {
    $logger->info('Event dispatched', ['event' => get_class($event)]);
}, priority: -10);

// Fraud-Check mit höchster Priorität
$provider->listen(OrderPlaced::class, function (OrderPlaced $event): void {
    if ($event->total > 10000) {
        $event->stopPropagation();  // Blockiert alle weiteren Listener
        throw new FraudCheckException($event->orderId);
    }
}, priority: 100);

// Bestätigung senden
$provider->listen(OrderPlaced::class, function (OrderPlaced $event): void {
    $mailer->sendOrderConfirmation($event->orderId);
}, priority: 10);

// Lagerbestand reservieren
$provider->listen(OrderPlaced::class, function (OrderPlaced $event): void {
    $inventory->reserve($event->orderId);
}, priority: 5);

// Dispatcher erstellen
$dispatcher = new EventDispatcher($provider);

// Im Use Case: Events sammeln und gebündelt dispatchen
$collector = new EventCollector();
$collector->record(new OrderPlaced('ORD-001', 299.99));
$collector->record(new PaymentProcessed('ORD-001', 'TXN-abc123'));

// Alles auf einmal dispatchen — nach erfolgreicher Persistenz
$collector->dispatchAll($dispatcher);