Vom Observer-Pattern zum standardisierten Vertrag
Ein eigenes Observer-Pattern hat fast jeder PHP-Entwickler schon einmal gebaut, meist mit einer Subject-Klasse und einer Liste von Callbacks. PSR-14 formalisiert dieses Muster in zwei klar getrennte Verträge. Wir implementieren beide selbst, inklusive Stoppable Events, und bauen daraus einen praktischen Domain-Event-Mechanismus ohne Framework.
Inhaltsverzeichnis
- 1. Was PSR-14 gegenüber einem klassischen Observer-Pattern ändert
- 2. EventDispatcherInterface im Detail
- 3. ListenerProviderInterface im Detail
- 4. Eine minimale Implementierung selbst bauen
- 5. Stoppable Events: kontrollierte Propagation
- 6. Praxisbeispiel: Ein Domain-Event-Mechanismus
- 7. Listener-Registrierung: Attribute statt manueller Konfiguration
- 8. Grenzen: Reihenfolge, Fehlerbehandlung und Asynchronität
- 9. Wann PSR-14 sich gegenüber einem Eigenbau-Observer lohnt
- 10. Zusammenfassung
- 11. FAQ
1. Was PSR-14 gegenüber einem klassischen Observer-Pattern ändert
Ein klassisches Observer-Pattern in PHP besteht meist aus einer Subject-Klasse mit einer internen Liste von Observern und einer notify()-Methode, die alle Observer in einer festen Schleife durchläuft. Diese Struktur funktioniert, koppelt aber Registrierung und Ausführung fest an eine einzige Klasse, und jede Erweiterung um neue Event-Typen bedeutet meist neue Methoden auf derselben Subject-Klasse.
PSR-14 löst diese Kopplung auf, indem es zwei unabhängige Verträge definiert: einen EventDispatcherInterface, der lediglich ein Event entgegennimmt und es weiterreicht, und einen davon komplett getrennten ListenerProviderInterface, der für ein gegebenes Event die passenden Listener liefert. Diese Trennung erlaubt es, Listener-Ermittlung und Dispatch-Logik unabhängig voneinander auszutauschen, etwa um Listener später aus einer Konfigurationsdatei statt aus Code zu laden.
2. EventDispatcherInterface im Detail
Der EventDispatcherInterface definiert nur eine einzige Methode, dispatch(object $event): object. Sie nimmt ein beliebiges Objekt als Event entgegen und gibt dasselbe Objekt zurück, gegebenenfalls mit Änderungen, die Listener am Event vorgenommen haben. Es gibt bewusst keine Typbeschränkung auf ein Event-Basisinterface, jedes PHP-Objekt kann als Event dienen.
Diese Offenheit unterscheidet PSR-14 von vielen älteren Event-Systemen, die Events als Strings mit Payload-Arrays behandeln. Ein Event als typisiertes Objekt ermöglicht IDE-Autovervollständigung, statische Analyse mit PHPStan und die Möglichkeit, dem Event eigene Methoden mitzugeben, etwa um den Verarbeitungsstatus abzufragen oder Zusatzdaten zu kapseln.
<?php
declare(strict_types=1);
namespace Psr\EventDispatcher;
interface EventDispatcherInterface
{
/**
* Reicht das Event an registrierte Listener weiter und gibt es
* anschliessend zurück, ggf. mit Aenderungen der Listener.
*/
public function dispatch(object $event): object;
}
3. ListenerProviderInterface im Detail
Der ListenerProviderInterface definiert die Methode getListenersForEvent(object $event): iterable. Sie nimmt das Event entgegen und liefert einen iterierbaren Satz von Callables zurück, die für dieses konkrete Event zuständig sind. Der Rückgabetyp iterable statt array ist bewusst gewählt, denn Listener können auch lazy über einen Generator geliefert werden, was bei sehr vielen registrierten Listenern Speicher spart.
Die Trennung von Dispatcher und Provider bedeutet konkret: Der Dispatcher weiß nichts darüber, wie Listener ermittelt werden, er fragt lediglich den Provider und ruft dann jeden gelieferten Listener mit dem Event als Argument auf. Diese Entkopplung macht es möglich, mehrere Provider zu kombinieren, etwa einen für Attribute-basierte Listener und einen zweiten für Listener aus einer YAML-Konfiguration.
<?php
declare(strict_types=1);
namespace Psr\EventDispatcher;
interface ListenerProviderInterface
{
/**
* @return iterable<callable(object): void>
*/
public function getListenersForEvent(object $event): iterable;
}
4. Eine minimale Implementierung selbst bauen
Eine einfache Implementierung von ListenerProviderInterface verwaltet intern eine Map von Event-Klassennamen zu Listener-Arrays. Beim Registrieren wird der Klassenname des Events als Schlüssel genutzt, beim Abfragen prüft die Implementierung mit instanceof, ob das konkrete Event-Objekt zu einem der registrierten Klassennamen passt, was auch Vererbung berücksichtigt.
Der Dispatcher selbst bleibt bewusst schlank: Er ruft getListenersForEvent() auf, iteriert über das Ergebnis und ruft jeden Listener mit dem Event auf. Zusätzlich prüft er nach jedem Listener-Aufruf, ob das Event ein StoppableEventInterface implementiert und die Propagation bereits gestoppt wurde, dazu mehr im nächsten Abschnitt.
<?php
declare(strict_types=1);
namespace App\Events;
use Psr\EventDispatcher\ListenerProviderInterface;
final class SimpleListenerProvider implements ListenerProviderInterface
{
/** @var array<class-string, list<callable(object): void>> */
private array $listeners = [];
/**
* @param callable(object): void $listener
*/
public function addListener(string $eventClass, callable $listener): void
{
$this->listeners[$eventClass][] = $listener;
}
public function getListenersForEvent(object $event): iterable
{
foreach ($this->listeners as $eventClass => $listeners) {
if ($event instanceof $eventClass) {
yield from $listeners;
}
}
}
}
5. Stoppable Events: kontrollierte Propagation
StoppableEventInterface ergänzt ein Event um eine einzige Methode, isPropagationStopped(): bool. Gibt diese Methode true zurück, muss der Dispatcher die Ausführung weiterer Listener für dieses Event abbrechen, ohne den Rest der Liste zu ignorieren, sondern aktiv zu prüfen und zu stoppen. Anders als bei einem klassischen Observer-Pattern ist dieses Verhalten im Standard selbst festgelegt, nicht Konvention einzelner Implementierungen.
In der Praxis bedeutet das: Ein Event, das gestoppt werden können soll, implementiert intern ein privates Flag und eine stopPropagation()-Methode, die dieses Flag setzt. Ein Listener, der die weitere Verarbeitung verhindern will, ruft diese Methode auf, statt eine Exception zu werfen. Das unterscheidet sich fundamental von einer Exception-basierten Steuerung, weil die restliche Anwendung nach dem Dispatch normal weiterläuft, nur eben ohne weitere Listener-Aufrufe für dieses eine Event.
<?php
declare(strict_types=1);
namespace App\Events;
use Psr\EventDispatcher\StoppableEventInterface;
final class OrderPlaced implements StoppableEventInterface
{
private bool $propagationStopped = false;
public function __construct(
public readonly string $orderId,
public readonly float $totalAmount,
) {
}
public function stopPropagation(): void
{
$this->propagationStopped = true;
}
public function isPropagationStopped(): bool
{
return $this->propagationStopped;
}
}
// Im Dispatcher: nach jedem Listener prüfen, ob weiter iteriert wird
foreach ($this->provider->getListenersForEvent($event) as $listener) {
if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
break;
}
$listener($event);
}
6. Praxisbeispiel: Ein Domain-Event-Mechanismus
Domain Events kapseln fachliche Vorkommnisse, etwa dass eine Bestellung aufgegeben wurde, ohne dass der Code, der das Event auslöst, wissen muss, wer darauf reagiert. Ein OrderService, der eine Bestellung speichert, dispatcht anschließend ein OrderPlaced-Event, unabhängig davon, ob später eine E-Mail-Benachrichtigung, eine Lagerbuchung oder ein Analytics-Tracking als Listener registriert ist.
Diese Entkopplung ist der eigentliche Wert von PSR-14 in der Praxis: Neue Reaktionen auf ein bestehendes Event lassen sich hinzufügen, ohne den OrderService jemals wieder anzufassen. Für die Registrierung bietet sich in PHP 8.4 ein Attribute-basierter Ansatz an, bei dem Listener-Methoden mit einem eigenen Attribut markiert werden und eine Registrierungsklasse diese Attribute zur Laufzeit oder beim Container-Aufbau per Reflection einsammelt.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Events\OrderPlaced;
use Psr\EventDispatcher\EventDispatcherInterface;
final class OrderService
{
public function __construct(
private readonly OrderRepositoryInterface $orders,
private readonly EventDispatcherInterface $dispatcher,
) {
}
public function place(Order $order): void
{
$this->orders->save($order);
// Fachliches Vorkommnis bekanntmachen, ohne die Listener zu kennen
$this->dispatcher->dispatch(
new OrderPlaced($order->id, $order->totalAmount),
);
}
}
7. Listener-Registrierung: Attribute statt manueller Konfiguration
Statt Listener manuell über addListener() zu registrieren, lässt sich mit einem eigenen PHP-Attribut deklarativ arbeiten. Eine Methode wird mit #[AsEventListener(OrderPlaced::class)] markiert, und eine Sammelklasse durchsucht beim Anwendungsstart alle relevanten Klassen per Reflection nach diesem Attribut, um den ListenerProvider automatisch zu befüllen.
Dieser Ansatz reduziert Boilerplate erheblich, hat aber einen Preis: Die Reflection-basierte Auswertung sollte in Produktionsumgebungen nicht bei jeder Anfrage neu laufen, sondern einmalig zur Build-Zeit oder beim ersten Anwendungsstart, mit anschließendem Caching der ermittelten Zuordnung in einer einfachen Array-Struktur oder Datei. Ohne dieses Caching entsteht bei vielen Listenern ein spürbarer Performance-Overhead durch wiederholte Reflection-Aufrufe.
8. Grenzen: Reihenfolge, Fehlerbehandlung und Asynchronität
PSR-14 legt keine Reihenfolge zwischen Listenern für dasselbe Event fest, das ist bewusst der jeweiligen Implementierung überlassen. Wer eine Ausführungsreihenfolge braucht, muss diese selbst über Prioritäten im ListenerProvider abbilden, etwa indem Listener mit einer Priorität registriert und vor der Rückgabe sortiert werden.
Ebenso wenig definiert der Standard, was passiert, wenn ein Listener eine Exception wirft. In einer eigenen Implementierung muss man bewusst entscheiden, ob ein fehlerhafter Listener die restliche Verarbeitung stoppt oder ob Fehler gesammelt und nach dem Durchlauf aller Listener gemeinsam behandelt werden. Für wirklich asynchrone Verarbeitung, etwa eine E-Mail, die Minuten später verschickt wird, ist PSR-14 zudem der falsche Baustein, dafür sind Message Queues das passendere Werkzeug, weil PSR-14 synchron innerhalb desselben Requests arbeitet.
9. Wann PSR-14 sich gegenüber einem Eigenbau-Observer lohnt
Für sehr kleine Skripte mit einem einzigen Event-Typ ist ein simples Observer-Pattern oft schneller umgesetzt und ausreichend verständlich. Sobald aber mehrere Event-Typen, mehrere Listener-Quellen oder eine Testsuite mit Mock-Dispatchern ins Spiel kommen, zahlt sich die Trennung von Dispatcher und Provider spürbar aus, weil beide Seiten unabhängig ausgetauscht und getestet werden können.
Auch die Interoperabilität spricht für PSR-14: Bibliotheken, die sich an den Standard halten, lassen sich mit dem eigenen Dispatcher kombinieren, ohne Adapter schreiben zu müssen. Wer bereits mit Symfony oder Laminas arbeitet, nutzt ohnehin PSR-14-kompatible Implementierungen, der Eigenbau bleibt vor allem für Lernzwecke und sehr schlanke, framework-freie Anwendungen relevant.
| Merkmal | Klassisches Observer-Pattern | PSR-14 Eigenbau | Framework-Implementierung |
|---|---|---|---|
| Verträge | Meist ein einziges Interface | Zwei getrennte Interfaces | Zwei getrennte Interfaces plus Erweiterungen |
| Listener-Quelle | Fest im Code registriert | Austauschbarer Provider | Attribute, Config, Container-Tags |
| Stoppable Events | Meist über Exceptions simuliert | StoppableEventInterface im Standard | StoppableEventInterface im Standard |
| Interoperabilität | Nicht standardisiert | PSR-14-konform | PSR-14-konform, plus Framework-Tooling |
| Aufwand | Minimal | Gering bis mittel | Keiner, bereits vorhanden |
Mironsoft
PHP-Modernisierung, Code-Qualität und Legacy-Refactoring
Gewachsener PHP-Code, der niemand mehr gern anfasst?
Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.
Legacy-Refactoring
Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.
Code-Qualität etablieren
PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.
Versions-Upgrade
PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.
10. Zusammenfassung
PSR-14 Event Dispatcher: Das Wichtigste auf einen Blick
Zwei Verträge
EventDispatcherInterface reicht Events weiter, ListenerProviderInterface ermittelt die passenden Listener.
Offene Events
Jedes PHP-Objekt kann als Event dienen, es gibt keine Pflicht zu einem Basisinterface.
Stoppable Events
isPropagationStopped() erlaubt kontrolliertes Abbrechen der Listener-Kette ohne Exceptions.
Domain Events
Fachliche Vorkommnisse werden dispatcht, ohne dass der Auslöser die Listener kennen muss.