Symfony Event Sourcing: Reconstructing State from Events
AI generated
SF
{ }
Symfony · Event Sourcing · CQRS · Domain-Driven Design
Symfony Event Sourcing:
Reconstructing State from Events

In traditional applications, the current state is stored and its history is discarded. Event sourcing turns this principle around: not the state, but the sequence of events that led to it, is the primary source of truth. The current state is always derivable, and therefore fully auditable, capable of time travel, and easy to debug.

20 min read Aggregates · Event Store · Replay · Projections · Snapshots Symfony 7.x · PHP 8.3+ · Doctrine DBAL

1. Why event sourcing and when it makes sense

Event sourcing is not a pattern for every application, but for certain domains it is the best-suited data storage model there is. The core idea: what happened in a system matters more than its current state. An online shop order object that only stores the status "cancelled" loses the information of who cancelled it, when, and why. An event sourcing approach instead stores: OrderPlaced, PaymentReceived, ItemShipped, OrderCancelled, each with a timestamp, user and context for every event. The current state can always be derived from this chain, and the complete history is available for free.

The practical benefit falls into three areas: auditing (a complete audit trail with no additional effort), debugging (every bug can be traced back to the exact event stream that caused it), and time travel (the state at any point in the past can be reconstructed by replaying the event stream up to that point). In regulated industries such as fintech, insurtech and health care, this is often not optional but legally required. Symfony provides all the tools needed to implement event sourcing without external frameworks, using Doctrine DBAL, Messenger and the event system.

2. Domain events: the language of the domain as code

Domain events are immutable objects that describe a fact that has already happened in the past. Their names are always in the past tense: OrderPlaced, PaymentFailed, UserRegistered. Domain events are not commands: they do not describe what should be done, but what has already been done. That is a fundamental difference: commands can be rejected, while events are facts that have already occurred and cannot be undone (at most compensated for by an opposing event).

In PHP, domain events are readonly classes with all relevant data of the event passed as constructor parameters. They contain no logic, no methods other than accessors, and are serializable for persistence in the event store. Every event carries an aggregate ID (to identify the event stream of a specific aggregate), a sequence number (for correct ordering), a timestamp and, optionally, a user context. This metadata is not part of the domain itself, but of the event store envelope that carries the event.


<?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: building state from events

An aggregate in event sourcing is not a Doctrine entity with direct setter methods. It is a class that builds its own state exclusively through domain events. The aggregate's methods validate business rules and produce events, but they do not change state directly. Instead, the produced event is applied through an internal apply() method that updates the aggregate's state. This mechanism ensures that the state is always consistent with the event history: whether it is built by freshly producing an event or by replaying from the event store, the result is identical.

This pattern has an important consequence for the design: the apply() methods contain only state changes, no business rules and no validation. Business rules live in the aggregate's command methods. This clearly separates what happens during replay (only building state, no re-validation) from what happens when the event is originally produced (validation plus state change). This distinction is what makes efficient replay possible: the event store delivers the events, the aggregate applies them in order and ends up in the correct state, without ever having to re-check the business rules.


<?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: persisting and loading events

The event store is the primary persistence layer in an event-sourced system. It stores events as immutable, append-only entries: there is no UPDATE and no DELETE, only INSERT. Every entry contains: the aggregate ID, the event type (class name), the serialized event data, a sequence number (for ordering within an aggregate) and metadata (timestamp, user context). The sequence number also serves as an optimistic locking mechanism: if two processes load and modify the same aggregate concurrently, one of them fails on save with a UNIQUE constraint error, because the same sequence number cannot be stored twice.

The event store implementation in Symfony uses Doctrine DBAL for direct SQL operations, without Doctrine ORM. Events are not entities, they are simple records in an append-only table. Serialization of the event data is done as JSON with a Symfony Serializer that uses PHP attributes for its mapping configuration. The event_type field contains the fully qualified class name of the event, so the correct target class can be chosen when deserializing. When an aggregate is loaded, all events for an aggregate ID are loaded in sequence-number order and applied to the aggregate one after another.

5. Event replay: reconstructing state at any point in time

Event replay is the ability to reconstruct the state of an aggregate, or of an entire system, at any point in the past. For a single aggregate, that means: filtering the event store by aggregate ID and loading only the events up to a given timestamp or sequence number. The aggregate is rebuilt with these events and represents its state at exactly that point in time. That is the time-travel capability event sourcing provides, one that no classic update-in-place system can reproduce.

System-wide replay matters for projections: when a new projection (for example, a new read model) is introduced, it must be backfilled retroactively with the historical events. To do that, all events from the store are loaded chronologically and run through the new projection. The result is a fully populated read model that looks as if the projection had existed from the very beginning. For large event streams, this replay can take hours; snapshots (next chapter) and parallel replay strategies solve that 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. Projections: building readable views on the event stream

Projections are the CQRS counterpart to the event-sourced aggregate: while the aggregate is responsible for write operations and builds its state from events, projections are responsible for read operations and build denormalized read models. A projection listens for events and updates its own table or record, optimized for queries, with exactly the fields and structure the display needs, without joins, without complex aggregations at query time.

In Symfony, projections are implemented as message handlers in Symfony Messenger that react to domain events. The advantage: they can be processed asynchronously by a queue worker, without blocking the write transaction. A projection for an order overview listens for OrderPlaced, PaymentReceived and OrderCancelled and updates an order_summaries table that can be queried directly for the admin overview. If the projection becomes corrupted, or a new view is needed, you simply drop the table and replay the entire event stream.

7. Snapshots: optimizing performance for long event streams

For long-lived aggregates that have accumulated hundreds or thousands of events, replay on load becomes expensive. A snapshot is a stored intermediate copy of the aggregate's state at a given point in time. When loading an aggregate, the latest snapshot is loaded first, then only the events since that snapshot are applied, instead of all events from the very beginning. The result is the same consistency as without a snapshot, but only a fraction of the events need to be loaded and processed.

Snapshots in Symfony are stored in their own table containing the serialized aggregate representation and the sequence number of the last applied event. One strategy for creating snapshots: after every nth modification (for example, every 100 events), or after a certain time interval. Important: snapshots are not a replacement for the event store, they are an optimization. Events are still stored in full and can be used for replay, audit and time travel. A corrupted snapshot can be regenerated at any time by rebuilding it from the event store.

8. Integrating Symfony Messenger as an event bus

Symfony Messenger is the natural integration point for event sourcing in Symfony. After the domain events are stored in the event store, they are additionally dispatched via Messenger as messages, for asynchronous processing by projections, notification handlers and external-system synchronizers. Messenger takes care of retry logic, dead-letter-queue handling and parallel workers, all infrastructure that is critical for event sourcing, because any failed event handler can leave projections in an inconsistent state.

Combining event sourcing with Symfony Messenger enables an outbox pattern: events are stored atomically with the aggregate in the event store, and only afterward delivered to subscribers by Messenger. If the Messenger dispatch fails, the events are still sitting in the event store and can be redispatched through a recovery mechanism. That guarantees exactly-once semantics: even if a worker process crashes, the event is not lost, it will be delivered again after the restart. Idempotent handlers (ones that can receive an event more than once without producing incorrect results) are the prerequisite for this.

9. Event sourcing vs. classic data storage

A direct comparison makes the strengths and weaknesses of event sourcing concrete. Not every project benefits from event sourcing: the implementation complexity and the mental model shift are real costs that must be justified by the benefits.

Criterion Classic data storage Event sourcing Assessment
Audit trail Additional audit table needed Included for free Event sourcing wins clearly
Read queries Direct SQL queries Projections required Classic is simpler
Debugging Only current state visible Full event history Event sourcing wins
Implementation complexity Low, CRUD with an ORM High, event store, projections, replay Classic is simpler
Time travel / replay Not possible Built in Only with event sourcing

Event sourcing pays off when: a complete audit trail is legally or functionally required, state must be reconstructed at past points in time, the domain is complex enough to justify the extra effort, or CQRS is used for separate read and write scaling. It does not pay off for: simple CRUD applications without complex domain logic, when the team has no DDD experience, or when the initial complexity cannot be offset by long-term maintainability gains.

Mironsoft

Symfony architecture, event sourcing and CQRS implementation

Want to implement event sourcing and CQRS in Symfony?

We support the introduction of event sourcing in Symfony projects, from aggregate modeling through the event store to projections, snapshots and Messenger integration.

Domain modeling

Designing aggregates, domain events and bounded contexts for event sourcing architectures

Event Store & Replay

Implementing an append-only event store, building a snapshot strategy and replay mechanism

Projections & CQRS

Building read models asynchronously with Symfony Messenger and optimizing them for high-performance queries

10. Summary

Event sourcing in Symfony is not magic, but it is a fundamental paradigm shift in data storage. State is not a truth that gets stored, it is the result of applying all historical events. Aggregates build their state through apply methods, the event store stores events append-only, and projections build denormalized read models for performant read access. The replay mechanism makes state reconstructible at any point in time and projections regenerable. Symfony Messenger distributes events asynchronously for scalable, decoupled processing.

The investment pays off for domains with high audit requirements, complex business logic and a need for full transparency. In regulated industries, the complete audit trail that comes as a free side effect of event sourcing is often decisive for compliance requirements. The combination with CQRS and Symfony Messenger produces an architecture optimized both for complex write operations and for high-performance read operations.

Symfony Event Sourcing, the Essentials at a Glance

Domain Events

Immutable readonly classes named in the past tense. Describe facts, not commands. Carry an aggregate ID, sequence number and timestamp.

Aggregate + Apply

Command methods validate and produce events. Apply methods only change state. The separation enables clean replay without re-validation.

Event Store

Append-only DBAL table. Sequence number = optimistic locking. Time travel by loading up to a given timestamp. Populate projections through a system-wide replay.

Projections

Messenger handlers build denormalized read models. Asynchronous, idempotent, regenerable at any time via event replay from the store.

11. FAQ: Symfony Event Sourcing

1What is event sourcing?
Not the state, but the event sequence, is the primary source of truth. Current state is reconstructed by replaying all events.
2What is a domain event?
Immutable object named in the past tense (OrderPlaced). Describes facts, not commands. Serializable for the event store.
3What is an aggregate?
Class that builds state through apply methods. Command methods validate and produce events. Apply methods only change state, no replay overhead.
4What is an event store?
Append-only table with aggregate ID, sequence, event type, data and timestamp. No UPDATE, no DELETE. Events are immutable facts.
5What is event replay?
Applying events in order onto a fresh aggregate. Loading up to a given timestamp gives time travel. For new projections: run through the entire stream again.
6What are projections?
Denormalized read models, built asynchronously by Messenger handlers. Optimized for read access. Regenerable at any time by replay.
7What are snapshots?
Stored state after n events. Loading: snapshot plus only events after it. Needed for long streams. Not a replacement for the event store.
8Preventing concurrent writes?
Sequence number as a UNIQUE constraint per aggregate ID. The second writer fails with a constraint error, this is optimistic locking.
9When does event sourcing not pay off?
Simple CRUD apps without a complex domain, no DDD knowledge in the team, when an audit trail is not required, or when complexity is not amortized by maintainability.
10Messenger in event sourcing?
Handlers receive events asynchronously and update projections. Outbox pattern: events stored in the store, then delivered via Messenger. Idempotent handlers prevent inconsistencies.