SF
{ }
Symfony · Event Sourcing · CQRS · Domain-Driven Design
Symfony Event Sourcing:
Den Zustand aus Events rekonstruieren

In traditionellen Anwendungen wird der aktuelle Zustand gespeichert und die History verworfen. Event Sourcing dreht dieses Prinzip um: Nicht der Zustand, sondern die Abfolge der Ereignisse, die zu ihm geführt haben, ist die primäre Datenquelle. Der aktuelle Zustand ist immer ableitbar — und damit vollständig auditierbar, zeitreisefähig und bugdebugbar.

20 Min. Lesezeit Aggregates · Event Store · Replay · Projektionen · Snapshots Symfony 7.x · PHP 8.3+ · Doctrine DBAL

1. Warum Event Sourcing und wann es sinnvoll ist

Event Sourcing ist kein Pattern für jede Anwendung, aber für bestimmte Domänen ist es das am besten geeignete Datenhaltungsmodell überhaupt. Der Kerngedanke: Was in einem System passiert ist, ist wichtiger als der aktuelle Zustand. Ein Online-Shop-Order-Objekt, das nur den Status "storniert" speichert, verliert die Information, wer wann storniert hat und warum. Ein Event-Sourcing-Ansatz speichert stattdessen: OrderPlaced, PaymentReceived, ItemShipped, OrderCancelled mit Zeitstempel, Benutzer und Kontext für jedes Ereignis. Der aktuelle Zustand ist immer aus dieser Kette ableitbar — und die vollständige Historie ist kostenlos verfügbar.

Der praktische Nutzen liegt in drei Bereichen: Auditing (lückenloser Audit-Trail ohne zusätzlichen Aufwand), Debugging (jeder Bug kann auf den exakten Event-Stream zurückverfolgt werden, der ihn verursacht hat) und Zeitreise (der Zustand zu jedem beliebigen Zeitpunkt in der Vergangenheit kann durch Replay des Event-Streams bis zu diesem Zeitpunkt rekonstruiert werden). Das ist in regulierten Branchen wie FinTech, InsureTech und Health-Care oft nicht optional, sondern gesetzlich vorgeschrieben. Symfony bietet mit Doctrine DBAL, Messenger und dem Event-System alle Werkzeuge, um Event Sourcing ohne externe Frameworks zu implementieren.

2. Domain Events: die Sprache der Domäne als Code

Domain Events sind unveränderliche Objekte, die einen Fakt beschreiben, der in der Vergangenheit stattgefunden hat. Der Name ist immer im Partizip Perfekt: OrderPlaced, PaymentFailed, UserRegistered. Domain Events sind keine Commands — sie beschreiben nicht, was getan werden soll, sondern was getan wurde. Das ist ein fundamentaler Unterschied: Commands können abgelehnt werden, Events sind Tatsachen, die bereits eingetreten sind und nicht rückgängig gemacht werden (höchstens durch ein gegenteiliges Event kompensiert werden).

In PHP sind Domain Events readonly-Klassen mit allen relevanten Daten des Ereignisses als Konstruktor-Parameter. Sie enthalten keine Logik, keine Methoden außer Accessors und sind serialisierbar für die Persistenz im Event Store. Jedes Event trägt eine aggregate-ID (um den Event-Stream eines konkreten Aggregates zu identifizieren), eine Sequenznummer (für die korrekte Reihenfolge), einen Zeitstempel und optional einen Benutzer-Kontext. Diese Metadaten sind nicht Teil der Domäne selbst, sondern des Event-Store-Envelopes, der das Event trägt.


<?php

declare(strict_types=1);

namespace App\Order\Event;

use App\Shared\ValueObject\OrderId;
use App\Shared\ValueObject\Money;

/**
 * Domain Event: fired when a customer places an order.
 * Immutable — describes a fact that already happened.
 */
final readonly class OrderPlaced
{
    public function __construct(
        public readonly OrderId $orderId,
        public readonly string  $customerId,
        public readonly Money   $totalAmount,
        public readonly array   $items,        // [{productId, qty, price}]
        public readonly \DateTimeImmutable $placedAt,
    ) {}
}

/**
 * Domain Event: fired when payment for an order is confirmed.
 */
final readonly class PaymentReceived
{
    public function __construct(
        public readonly OrderId $orderId,
        public readonly string  $transactionId,
        public readonly Money   $amount,
        public readonly \DateTimeImmutable $receivedAt,
    ) {}
}

/**
 * Domain Event: fired when a customer cancels an order.
 * Contains reason for full audit trail — no information is lost.
 */
final readonly class OrderCancelled
{
    public function __construct(
        public readonly OrderId $orderId,
        public readonly string  $cancelledBy, // user id or 'system'
        public readonly string  $reason,
        public readonly \DateTimeImmutable $cancelledAt,
    ) {}
}

3. Aggregates: Zustand durch Events aufbauen

Ein Aggregate in Event Sourcing ist kein Doctrine-Entity mit direkten Setter-Methoden. Es ist eine Klasse, die ihren eigenen Zustand ausschließlich durch Domain Events aufbaut. Die Methoden des Aggregates validieren Geschäftsregeln und erzeugen Events — aber sie ändern den Zustand nicht direkt. Stattdessen wird das erzeugte Event durch eine interne apply()-Methode angewendet, die den Zustand des Aggregates aktualisiert. Dieser Mechanismus stellt sicher, dass der Zustand immer konsistent mit der Event-History ist: ob er durch frisches Event-Erzeugen oder durch Replay aus dem Event Store aufgebaut wird, ist identisch.

Das Muster hat eine wichtige Konsequenz für das Design: Die apply()-Methoden enthalten ausschließlich Zustandsänderungen, keine Geschäftsregeln und keine Validierungen. Die Geschäftsregeln sitzen in den Command-Methoden des Aggregates. Das trennt klar, was beim Replay passiert (nur Zustandsaufbau, keine Re-Validierung) von dem, was beim ursprünglichen Erzeugen des Events passiert (Validierung + Zustandsänderung). Dieser Unterschied ermöglicht den effizienten Replay: Der Event Store liefert die Events, das Aggregate appliert sie der Reihe nach und ist danach im korrekten Zustand — ohne die Geschäftsregeln ein zweites Mal prüfen zu müssen.


<?php

declare(strict_types=1);

namespace App\Order;

use App\Order\Event\{OrderPlaced, PaymentReceived, OrderCancelled};
use App\Shared\ValueObject\{OrderId, Money};

/**
 * Order Aggregate — state is built exclusively through Domain Events.
 * Business rules in command methods; state changes only in apply() methods.
 */
final class Order
{
    private OrderStatus $status = OrderStatus::Draft;
    private bool        $paymentReceived = false;
    private array       $recordedEvents  = [];

    private function __construct(
        private readonly OrderId $id,
    ) {}

    /**
     * Factory method: create a new order and record the OrderPlaced event.
     */
    public static function place(OrderId $id, string $customerId, array $items, Money $total): self
    {
        $order = new self($id);
        $order->recordThat(new OrderPlaced($id, $customerId, $total, $items, new \DateTimeImmutable()));
        return $order;
    }

    /**
     * Command method: confirm payment — validates business rules, records event.
     */
    public function receivePayment(string $transactionId, Money $amount): void
    {
        if ($this->status === OrderStatus::Cancelled) {
            throw new \DomainException('Cannot receive payment for a cancelled order.');
        }
        if ($this->paymentReceived) {
            throw new \DomainException('Payment already received for this order.');
        }
        $this->recordThat(new PaymentReceived($this->id, $transactionId, $amount, new \DateTimeImmutable()));
    }

    /**
     * Command method: cancel the order — validates state, records event.
     */
    public function cancel(string $cancelledBy, string $reason): void
    {
        if ($this->paymentReceived) {
            throw new \DomainException('Cannot cancel an order that has already been paid.');
        }
        $this->recordThat(new OrderCancelled($this->id, $cancelledBy, $reason, new \DateTimeImmutable()));
    }

    // Apply methods — state changes only, no business logic, no validation
    private function applyOrderPlaced(OrderPlaced $event): void
    {
        $this->status = OrderStatus::Pending;
    }

    private function applyPaymentReceived(PaymentReceived $event): void
    {
        $this->paymentReceived = true;
        $this->status = OrderStatus::Paid;
    }

    private function applyOrderCancelled(OrderCancelled $event): void
    {
        $this->status = OrderStatus::Cancelled;
    }

    // Infrastructure: record event internally and apply immediately
    private function recordThat(object $event): void
    {
        $this->applyEvent($event);
        $this->recordedEvents[] = $event;
    }

    // Replay from event store — apply without recording
    public function applyEvent(object $event): void
    {
        $method = 'apply' . (new \ReflectionClass($event))->getShortName();
        if (method_exists($this, $method)) {
            $this->{$method}($event);
        }
    }

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

    public function getId(): OrderId { return $this->id; }
}

4. Event Store: Events persistieren und laden

Der Event Store ist die primäre Persistenzschicht im Event-Sourcing-System. Er speichert Events als unveränderliche, append-only Einträge — es gibt kein UPDATE und kein DELETE, nur INSERT. Jeder Eintrag enthält: die Aggregate-ID, den Event-Typ (Klassenname), die serialisierten Event-Daten, eine Sequenznummer (für die Reihenfolge innerhalb eines Aggregates) und Metadaten (Zeitstempel, Benutzer-Kontext). Die Sequenznummer dient gleichzeitig als Optimistic-Locking-Mechanismus: Wenn zwei Prozesse dasselbe Aggregate gleichzeitig laden und modifizieren, schlägt einer beim Speichern mit einem UNIQUE-Constraint-Fehler fehl, weil dieselbe Sequenznummer nicht zweimal gespeichert werden kann.

Die Implementierung des Event Stores in Symfony nutzt Doctrine DBAL für direkte SQL-Operationen, ohne Doctrine ORM. Events sind keine Entities — sie sind einfache Datensätze in einer Append-only-Tabelle. Die Serialisierung der Event-Daten erfolgt per JSON mit einem Symfony Serializer, der PHP-Attribute für die Mapping-Konfiguration nutzt. Das event_type-Feld enthält den vollständigen Klassenname des Events, um beim Deserialisieren die richtige Zielklasse zu wählen. Beim Laden eines Aggregates werden alle Events für eine Aggregate-ID in Sequenznummer-Reihenfolge geladen und der Reihe nach auf das Aggregate appliert.

5. Event Replay: den Zustand zu jedem Zeitpunkt rekonstruieren

Event Replay ist die Fähigkeit, den Zustand eines Aggregates oder eines gesamten Systems zu einem beliebigen Zeitpunkt in der Vergangenheit zu rekonstruieren. Für ein einzelnes Aggregate bedeutet das: Den Event Store nach der Aggregate-ID filtern und nur Events bis zu einem bestimmten Zeitstempel oder einer bestimmten Sequenznummer laden. Das Aggregate wird mit diesen Events neu aufgebaut und repräsentiert seinen Zustand zu genau diesem Zeitpunkt — das ist die Zeitreisefähigkeit, die Event Sourcing bietet und die kein klassisches Update-in-Place-System reproduzieren kann.

System-weiter Replay ist für Projektionen relevant: Wenn eine neue Projektion (z.B. ein neues Read-Model) eingeführt wird, muss sie rückwirkend mit den historischen Events befüllt werden. Dafür werden alle Events aus dem Store chronologisch geladen und durch die neue Projektion gejagt. Das Ergebnis ist ein vollständig befülltes Read-Model, das so aussieht, als wäre die Projektion von Anfang an dabei gewesen. Für große Event-Streams kann dieser Replay stunden dauern — Snapshots (nächstes Kapitel) und parallele Replay-Strategien lösen das Performance-Problem.


<?php

declare(strict_types=1);

namespace App\Order\Infrastructure;

use App\Order\Order;
use App\Shared\ValueObject\OrderId;
use Doctrine\DBAL\Connection;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * Event Store implementation using Doctrine DBAL — append-only, no ORM.
 */
final readonly class DoctrineEventStore
{
    public function __construct(
        private Connection          $connection,
        private SerializerInterface $serializer,
    ) {}

    /**
     * Append recorded events to the event store — fails on duplicate sequence (optimistic locking).
     */
    public function append(OrderId $aggregateId, array $events, int $expectedVersion): void
    {
        $this->connection->beginTransaction();
        try {
            foreach ($events as $i => $event) {
                $this->connection->insert('event_store', [
                    'aggregate_id'   => (string) $aggregateId,
                    'aggregate_type' => Order::class,
                    'event_type'     => $event::class,
                    'event_data'     => $this->serializer->serialize($event, 'json'),
                    'sequence'       => $expectedVersion + $i + 1,
                    'occurred_at'    => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM),
                ]);
            }
            $this->connection->commit();
        } catch (\Throwable $e) {
            $this->connection->rollBack();
            throw $e; // UNIQUE constraint violation on sequence = optimistic locking failure
        }
    }

    /**
     * Load all events for an aggregate, optionally up to a specific timestamp (time travel).
     *
     * @return list<object> Domain event objects in sequence order
     */
    public function load(OrderId $aggregateId, ?\DateTimeImmutable $until = null): array
    {
        $qb = $this->connection->createQueryBuilder()
            ->select('*')
            ->from('event_store')
            ->where('aggregate_id = :id')
            ->setParameter('id', (string) $aggregateId)
            ->orderBy('sequence', 'ASC');

        if ($until !== null) {
            $qb->andWhere('occurred_at <= :until')
               ->setParameter('until', $until->format(\DateTimeInterface::ATOM));
        }

        return array_map(
            fn(array $row) => $this->serializer->deserialize(
                $row['event_data'],
                $row['event_type'],
                'json',
            ),
            $qb->fetchAllAssociative(),
        );
    }
}

6. Projektionen: lesbare Sichten auf den Event-Stream aufbauen

Projektionen sind das CQRS-Gegenstück zum Event-Sourcing-Aggregat: Während das Aggregat für Schreiboperationen verantwortlich ist und seinen Zustand durch Events aufbaut, sind Projektionen für Leseoperationen zuständig und bauen denormalisierte Read-Models auf. Eine Projektion hört auf Events und aktualisiert eine eigene Tabelle oder einen eigenen Datensatz, der für Queries optimiert ist — mit genau den Feldern und der Struktur, die die Anzeige braucht, ohne Joins, ohne komplexe Aggregationen zur Abfragezeit.

In Symfony werden Projektionen als Message-Handler im Symfony Messenger implementiert, die auf Domain Events reagieren. Der Vorteil: Sie können asynchron durch einen Queue-Worker verarbeitet werden, ohne die Schreib-Transaktion zu blockieren. Eine Projektion für eine Bestellübersicht hört auf OrderPlaced, PaymentReceived und OrderCancelled und aktualisiert eine order_summaries-Tabelle, die direkt für die Admin-Übersicht abgefragt werden kann. Wenn die Projektion korrupt wird oder eine neue Ansicht gebraucht wird, löscht man die Tabelle und replayed den gesamten Event-Stream neu.

7. Snapshots: Performance bei langen Event-Streams optimieren

Bei langlebigen Aggregaten, die hunderte oder tausende Events akkumuliert haben, wird der Replay beim Laden teuer. Ein Snapshot ist ein Zwischenspeicher des Aggregat-Zustands zu einem bestimmten Zeitpunkt. Beim Laden eines Aggregates wird zuerst der neueste Snapshot geladen, dann nur noch die Events seit dem Snapshot appliert — statt alle Events seit Anbeginn. Das Ergebnis ist dieselbe Konsistenz wie ohne Snapshot, aber ein Bruchteil der Events muss geladen und verarbeitet werden.

Snapshots in Symfony werden in einer eigenen Tabelle gespeichert, die die serialisierte Aggregate-Repräsentation und die Sequenznummer des letzten applierten Events enthält. Eine Strategie für das Erstellen von Snapshots: nach jeder n-ten Modifikation (z.B. alle 100 Events) oder nach einem bestimmten Zeitintervall. Wichtig: Snapshots sind kein Ersatz für den Event Store — sie sind eine Optimierung. Die Events werden weiterhin vollständig gespeichert und können für Replay, Audit und Zeitreise genutzt werden. Ein korrupter Snapshot kann jederzeit durch Rebuild aus dem Event Store regeneriert werden.

8. Symfony Messenger als Event-Bus integrieren

Der Symfony Messenger ist die natürliche Integration für Event Sourcing in Symfony. Nach dem Speichern der Domain Events im Event Store werden sie zusätzlich über den Messenger als Messages dispatched — für asynchrone Verarbeitung durch Projektionen, Notification-Handler und External-System-Synchronisatoren. Der Messenger übernimmt Retry-Logic, Dead-Letter-Queue-Handling und parallele Worker — alles Infrastruktur, die beim Event Sourcing kritisch ist, weil jeder ausgefallene Event-Handler einen inkonsistenten Zustand in Projektionen verursachen kann.

Die Kombination von Event Sourcing und Symfony Messenger ermöglicht ein Outbox-Pattern: Events werden atomar mit dem Aggregate im Event Store gespeichert und danach vom Messenger zu Subscribers zugestellt. Wenn der Messenger-Dispatch fehlschlägt, liegen die Events noch im Event Store und können durch einen Recovery-Mechanismus erneut dispatched werden. Das garantiert Exactly-Once-Semantik: Selbst wenn ein Worker-Prozess abstürzt, wird der Event nicht verloren — er wird nach dem Neustart erneut zugestellt. Idempotente Handler (die einen Event mehrfach empfangen, ohne inkorrekte Ergebnisse zu produzieren) sind die Voraussetzung dafür.

9. Event Sourcing vs. klassische Datenhaltung

Der direkte Vergleich macht die Stärken und Schwächen von Event Sourcing konkret. Nicht jedes Projekt profitiert von Event Sourcing — die Komplexität der Implementierung und das mentale Modell-Wechsel sind reale Kosten, die durch die Vorteile gerechtfertigt sein müssen.

Kriterium Klassische Datenhaltung Event Sourcing Bewertung
Audit Trail Zusätzliche Audit-Tabelle nötig Kostenlos inklusive Event Sourcing gewinnt deutlich
Lesende Queries Direkte SQL-Abfragen Projektionen nötig Klassisch einfacher
Debugging Nur aktueller Zustand sichtbar Vollständige Event-History Event Sourcing gewinnt
Implementierungskomplexität Gering — CRUD mit ORM Hoch — Event Store, Projektionen, Replay Klassisch einfacher
Zeitreise / Replay Nicht möglich Eingebaut Nur in Event Sourcing

Event Sourcing lohnt sich, wenn: lückenloser Audit-Trail gesetzlich oder fachlich gefordert ist, der Zustand zu vergangenen Zeitpunkten rekonstruiert werden muss, die Domäne komplex genug ist, um den Mehraufwand zu rechtfertigen, oder wenn CQRS für separate Lese- und Schreib-Skalierung eingesetzt wird. Es lohnt sich nicht bei: einfachen CRUD-Anwendungen ohne komplexe Domänenlogik, wenn das Team keine DDD-Erfahrung hat, oder wenn die initiale Komplexität nicht durch langfristige Wartbarkeitsgewinne kompensiert werden kann.

Mironsoft

Symfony-Architektur, Event Sourcing und CQRS-Implementierung

Event Sourcing und CQRS in Symfony implementieren?

Wir begleiten die Einführung von Event Sourcing in Symfony-Projekten — von der Aggregate-Modellierung über den Event Store bis zu Projektionen, Snapshots und Messenger-Integration.

Domain Modellierung

Aggregates, Domain Events und Bounded Contexts für Event-Sourcing-Architekturen entwerfen

Event Store & Replay

Append-only Event Store implementieren, Snapshot-Strategie und Replay-Mechanismus aufbauen

Projektionen & CQRS

Read-Models mit Symfony Messenger asynchron aufbauen und für hochperformante Queries optimieren

10. Zusammenfassung

Event Sourcing in Symfony ist kein Hexenwerk, aber ein fundamentaler Paradigmenwechsel in der Datenhaltung. Der Zustand ist keine Wahrheit, die gespeichert wird — er ist das Ergebnis der Anwendung aller historischen Events. Aggregates bauen ihren Zustand durch Apply-Methoden auf, der Event Store speichert Events append-only, und Projektionen bauen denormalisierte Read-Models für performante Lesezugriffe. Der Replay-Mechanismus macht den Zustand zu jedem Zeitpunkt rekonstruierbar und Projektionen regenerierbar. Symfony Messenger verteilt Events asynchron für skalierbare, entkoppelte Verarbeitung.

Die Investition lohnt sich für Domänen mit hohen Audit-Anforderungen, komplexer Geschäftslogik und dem Bedürfnis nach vollständiger Transparenz. In regulierten Branchen ist der lückenlose Audit-Trail, der bei Event Sourcing als kostenloser Nebeneffekt anfällt, oft entscheidend für Compliance-Anforderungen. Die Kombination mit CQRS und Symfony Messenger ergibt eine Architektur, die sowohl für komplexe Schreiboperationen als auch für hochperformante Leseoperationen optimiert ist.

Symfony Event Sourcing — Das Wichtigste auf einen Blick

Domain Events

Unveränderliche readonly-Klassen im Partizip Perfekt. Beschreiben Fakten, keine Commands. Tragen Aggregate-ID, Sequenz und Zeitstempel.

Aggregate + Apply

Command-Methoden validieren und erzeugen Events. Apply-Methoden ändern nur den Zustand. Trennung ermöglicht sauberen Replay ohne Re-Validierung.

Event Store

Append-only DBAL-Tabelle. Sequenznummer = Optimistic Locking. Zeitreise durch Laden bis zu einem Zeitstempel. Projektionen durch System-Replay befüllen.

Projektionen

Messenger-Handler bauen denormalisierte Read-Models auf. Asynchron, idempotent, jederzeit neu generierbar durch Event-Replay aus dem Store.

11. FAQ: Symfony Event Sourcing

1Was ist Event Sourcing?
Nicht der Zustand, sondern die Ereignis-Abfolge ist die primäre Datenquelle. Aktueller Zustand wird durch Replay aller Events rekonstruiert.
2Was ist ein Domain Event?
Unveränderliches Objekt im Partizip Perfekt (OrderPlaced). Beschreibt Fakten, keine Commands. Serialisierbar für den Event Store.
3Was ist ein Aggregate?
Klasse, die Zustand durch Apply-Methoden aufbaut. Command-Methoden validieren und erzeugen Events. Apply-Methoden nur Zustandsänderung — kein Replay-Overhead.
4Was ist ein Event Store?
Append-only Tabelle mit Aggregate-ID, Sequenz, Event-Typ, Daten und Zeitstempel. Kein UPDATE, kein DELETE. Events sind unveränderliche Fakten.
5Was ist Event Replay?
Events der Reihe nach auf frisches Aggregate applieren. Bis zu einem Zeitstempel laden = Zeitreise. Für neue Projektionen: gesamten Stream neu durchlaufen.
6Was sind Projektionen?
Denormalisierte Read-Models, asynchron per Messenger-Handler aufgebaut. Optimiert für Lesezugriffe. Jederzeit neu regenerierbar durch Replay.
7Was sind Snapshots?
Gespeicherter Zustand nach n Events. Laden: Snapshot + nur Events danach. Bei langen Streams nötig. Kein Ersatz für den Event Store.
8Concurrent Writes verhindern?
Sequenznummer als UNIQUE-Constraint pro Aggregate-ID. Zweiter Schreiber schlägt mit Constraint-Fehler fehl — das ist Optimistic Locking.
9Wann lohnt sich Event Sourcing nicht?
Einfache CRUD-Apps ohne komplexe Domäne, fehlendes DDD-Wissen im Team, wenn Audit-Trail nicht gefordert ist oder Komplexität nicht durch Wartbarkeit amortisiert wird.
10Messenger in Event Sourcing?
Handler empfangen Events asynchron und aktualisieren Projektionen. Outbox-Pattern: Events im Store gespeichert, dann per Messenger zugestellt. Idempotente Handler verhindern Inkonsistenzen.