Ein häufiges Problem in eventgesteuerten PHP-Anwendungen ist der Dual-Write-Fehler: Daten werden erfolgreich in die Datenbank geschrieben, doch das zugehörige Event erreicht nicht den Message-Broker. Der Outbox-Pattern löst dieses Problem, indem er beide Operationen in einer einzigen Transaktion vereint – ohne externe Bibliotheken und mit minimalem Codeaufwand.
Warum klassische Ansätze scheitern
In vielen Projekten werden zwei gängige Muster eingesetzt, um Events nach einer Datenbankoperation zu veröffentlichen. Beide führen jedoch zu neuen Problemen:
- Vorschreiben vor dem Speichern: Das Event wird zuerst an den Broker gesendet, dann die Datenbank aktualisiert. Fällt der Datenbankschritt fehl, existiert ein Event für einen nicht existierenden Datensatz. Konsumenten erhalten inkonsistente Informationen und müssen mit defensiven Null-Werten umgehen.
- Speichern mit Fehlerbehandlung: Die Transaktion wird abgeschlossen, doch das Event-Publishing scheitert. Eine try-catch-Anweisung loggt den Fehler, doch ohne Replay-Mechanismus bleibt das Event verloren. Spätere Log-Analysen oder manuelle Skripte können die verlorenen Events nicht zuverlässig rekonstruieren, besonders wenn sich die Event-Struktur seit dem Vorfall geändert hat.
Der Kern des Problems liegt in der fehlenden atomaren Kommit-Möglichkeit zwischen Datenbank und Message-Broker. Selbst zweiphasige Commit-Protokolle wie XA sind in PHP-Umgebungen mit RabbitMQ selten praktikabel und führen oft zu komplexen Implementierungen.
Die Lösung: Der Outbox-Pattern
Der Outbox-Pattern umgeht diese Einschränkungen, indem er die Event-Veröffentlichung direkt in die Datenbank integriert. Statt zwei Systeme zu synchronisieren, wird das Event als zusätzliche Zeile in einer dedizierten Tabelle gespeichert – innerhalb derselben Transaktion wie die Hauptdaten. Ein separater Worker liest diese Zeilen aus und veröffentlicht sie an den Broker. Scheitert der Publish-Versuch, bleibt die Event-Zeile erhalten und wird beim nächsten Durchlauf erneut verarbeitet.
Die Datenbanktabelle: Struktur und Optimierung
Eine einfache, aber effektive Tabelle reicht aus, um die Events zu speichern. Die folgende PostgreSQL-Migration (kompatibel mit SQLite und MySQL 8 mit minimalen Anpassungen) zeigt die notwendigen Felder:
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
dispatched_at TIMESTAMPTZ
);
CREATE INDEX outbox_events_pending_idx ON outbox_events (occurred_at)
WHERE dispatched_at IS NULL;- `id`: Eine UUIDv7 als primärer Schlüssel, die zeitlich geordnet ist und später eine sortierte Verarbeitung ermöglicht.
- `aggregate_id`: Referenziert den Domain-Objekt-Identifikator (z. B. die Bestell-ID).
- `event_type`: Der Typ des Events (z. B.
order.placed). - `payload`: Ein JSONB-Feld, das die Event-Daten als flaches Dictionary speichert. Wichtig ist, dass dieser Wert als unveränderliche Momentaufnahme gespeichert wird – spätere Schema-Änderungen der Domain-Objekte dürfen die alten Payloads nicht beeinflussen.
- `occurred_at`: Der Zeitpunkt, zu dem das Event ausgelöst wurde.
- `dispatched_at`: Ein Timestamp, der auf
NULLgesetzt bleibt, solange das Event noch nicht veröffentlicht wurde. Ein partieller Index sorgt dafür, dass nur diese Zeilen effizient abgefragt werden, selbst wenn die Tabelle wächst.
Events innerhalb der Transaktion speichern
Der entscheidende Vorteil des Outbox-Patterns liegt darin, dass das Event direkt in der gleichen Transaktion wie die Domain-Logik gespeichert wird. Hier ein Beispiel für einen PlaceOrder-Use-Case in PHP 8.3 mit PDO:
<?php
declare(strict_types=1);
namespace App\Application\PlaceOrder;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use App\Application\Outbox\OutboxWriter;
final readonly class PlaceOrder
{
public function __construct(
private \PDO $db,
private OrderRepository $orders,
private OutboxWriter $outbox,
) {}
public function handle(PlaceOrderCommand $cmd): string
{
$order = Order::place(
customerId: $cmd->customerId,
items: $cmd->items,
);
$this->db->beginTransaction();
try {
$this->orders->save($order);
$this->outbox->write(
aggregateId: $order->id,
eventType: 'order.placed',
payload: [
'order_id' => $order->id,
'customer_id' => $order->customerId,
'total_cents' => $order->totalCents,
],
);
$this->db->commit();
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
return $order->id;
}
}In diesem Beispiel wird die OutboxWriter-Instanz innerhalb der Transaktion aufgerufen. Sie schreibt das Event als Zeile in die outbox_events-Tabelle. Beide Operationen – das Speichern der Bestellung und das Schreiben des Events – werden atomar entweder bestätigt oder zurückgerollt. Fällt eine der Operationen fehl, bleiben beide ohne Wirkung.
Der OutboxWriter: Minimaler Code für maximale Wirkung
Die Implementierung des OutboxWriter ist bewusst schlank gehalten und kommt mit nur zwölf Zeilen Code aus:
<?php
declare(strict_types=1);
namespace App\Application\Outbox;
use Ramsey\Uuid\Uuid;
final readonly class OutboxWriter
{
public function __construct(private \PDO $db) {}
public function write(
string $aggregateId,
string $eventType,
array $payload,
): void {
$stmt = $this->db->prepare(
'INSERT INTO outbox_events (id, aggregate_id, event_type, payload)
VALUES (:id, :agg, :type, :payload)'
);
$stmt->execute([
':id' => Uuid::uuid7()->toString(),
':agg' => $aggregateId,
':type' => $eventType,
':payload' => json_encode($payload, JSON_THROW_ON_ERROR),
]);
}
}- UUIDv7: Wird für die eindeutige Identifizierung des Events genutzt und ermöglicht eine zeitlich geordnete Verarbeitung.
- JSON-Encoding: Der Payload wird direkt als JSON serialisiert und in der Datenbank gespeichert. Fehler beim Encoding führen zu einem sofortigen Abbruch.
Falls die ramsey/uuid-Bibliothek nicht verfügbar ist, kann alternativ jede andere UUIDv7-Implementierung oder ein einfacher Generator verwendet werden. Der Schlüssel ist die Konsistenz der Event-IDs über die Zeit.
Der Worker: Events zuverlässig publizieren
Der Worker ist der unsichtbare Held des Outbox-Patterns. Seine Aufgabe besteht darin, regelmäßig die outbox_events-Tabelle nach neuen, noch nicht publizierten Events abzufragen und diese an den Message-Broker zu senden. Ein typischer Workflow könnte so aussehen:
- Abfrage: Der Worker sucht nach Events, bei denen
dispatched_ataufNULLgesetzt ist, und sortiert sie nachoccurred_atfür eine geordnete Verarbeitung. - Publishing: Für jedes Event wird eine Nachricht an RabbitMQ gesendet.
- Bestätigung: Nach erfolgreichem Publish wird das
dispatched_at-Feld auf den aktuellen Timestamp gesetzt. - Wiederholung: Scheitert das Publizieren, bleibt das Event in der Tabelle und wird beim nächsten Durchlauf erneut versucht.
Die Implementierung des Workers hängt von der gewählten Technologie ab – ob ein einfacher PHP-CLI-Script, ein Kubernetes-Job oder ein spezialisierter Message-Poller genutzt wird. Entscheidend ist die Zuverlässigkeit und Skalierbarkeit des Ansatzes.
Fazit: Ein Pattern für robuste Event-Verarbeitung
Der Outbox-Pattern bietet eine elegante Lösung für das Dual-Write-Problem in eventgesteuerten PHP-Anwendungen. Mit minimalem Codeaufwand – etwa 80 Zeilen PHP 8.3 plus einer einfachen Migration – lässt sich eine zuverlässige Event-Verarbeitung umsetzen, die Datenbank- und Broker-Operationen atomar vereint.
Durch die Integration in die bestehende Transaktionslogik entfällt die Notwendigkeit komplexer Fehlerbehandlungsmechanismen oder manueller Replay-Skripte. Die Verwendung von UUIDv7 und JSONB stellt sicher, dass Events auch über längere Zeiträume hinweg konsistent verarbeitet werden können. Für Entwickler, die auf externe Bibliotheken verzichten möchten, ist dieser Ansatz besonders attraktiv – und ein Beweis dafür, dass manchmal die einfachsten Lösungen die robustesten sind.
KI-Zusammenfassung
Learn how to fix dual-write problems in PHP applications with the outbox pattern. Store events in the database and publish them reliably without external libraries.