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 kompatibel —
EventDispatcherInterface,ListenerProviderInterfaceundStoppableEventInterfacevollstä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
composer require jardisadapter/eventdispatcherGitHub: jardisAdapter/eventdispatcher
Grundlegende Nutzung
Listener registrieren und Events dispatchen
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:
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:
$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:
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:
$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:
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.
$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 dispatchenFluent API
$collector
->record(new OrderCreated($orderId))
->record(new PaymentReceived($paymentId))
->record(new InventoryReserved($itemId))
->dispatchAll($dispatcher);Listener entfernen
$listener = function (OrderCreated $event): void { /* ... */ };
$provider->listen(OrderCreated::class, $listener);
$provider->remove(OrderCreated::class, $listener); // Sicher: No-Op wenn nicht registriertFehlerbehandlung
Das Package definiert keine eigenen Exceptions. Fehler in Listenern propagieren unverändert zum Aufrufer:
| Situation | Verhalten |
|---|---|
| Listener wirft Exception | Propagiert unverändert zum Caller |
| Kein Listener registriert | Event wird stillschweigend ignoriert |
| Event bereits gestoppt | Keine 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 ObjectDesign-Entscheidungen
| Entscheidung | Begründung |
|---|---|
Kein EventSubscriberInterface | Statische Subscriber verletzen das Prinzip expliziter Abhängigkeiten |
| Kein Async Dispatch | Synchron by Design. Für Cross-Process-Events: jardisadapter/messaging |
| Kein Event Store/Sourcing | Persistenz ist Infrastruktur-Verantwortung |
| Kein Listener Discovery | Kein Classpath-Scanning, keine Reflection. Registrierung ist explizit |
API-Referenz
EventDispatcher
| Methode | Signatur | Beschreibung |
|---|---|---|
__construct | __construct(ListenerProviderInterface $provider) | PSR-14 Provider injizieren |
dispatch | dispatch(object $event): object | Event dispatchen, Listener aufrufen |
ListenerProvider
| Methode | Signatur | Beschreibung |
|---|---|---|
listen | listen(string $eventClass, callable $listener, int $priority = 0): void | Listener registrieren |
remove | remove(string $eventClass, callable $listener): void | Listener entfernen |
getListenersForEvent | getListenersForEvent(object $event): iterable | Listener für Event abrufen (sortiert) |
Event
| Methode | Signatur | Beschreibung |
|---|---|---|
isPropagationStopped | isPropagationStopped(): bool | Propagation gestoppt? |
stopPropagation | stopPropagation(): void | Propagation stoppen |
EventCollector
| Methode | Signatur | Beschreibung |
|---|---|---|
record | record(object $event): self | Event aufzeichnen |
dispatchAll | dispatchAll(EventDispatcherInterface $dispatcher): self | Alle Events dispatchen + leeren |
events | events(): array | Gesammelte Events inspizieren |
clear | clear(): self | Events verwerfen |
count | count(): int | Anzahl gesammelter Events |
Vollständiges Beispiel
Ein typisches DDD-Setup mit Domain Events, priorisierten Listenern, Type-Hierarchy-Matching und EventCollector:
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);