Event Sourcing in PHP: Fundamentals Without a Framework
AI generated
8.4
PHP, Resilience, Architecture Patterns
Event Sourcing in PHP
fundamentals without a framework dependency

Most applications store only the current state of an entity and overwrite it on every change, permanently losing any information about how that state came to be. Event sourcing turns this principle around: instead of the current state, it stores the complete, immutable sequence of every event that led to that state, from which the current state can be recomputed at any time.

17 min read Event Store, Aggregate Replay, Snapshotting

1. State as a sequence of events instead of a current snapshot

The classic CRUD approach stores the current balance of a bank account in a single column and overwrites that value on every deposit or withdrawal with an UPDATE. Once that UPDATE has run, there is no way to tell how the balance came to be, how many individual transactions contributed to it, or exactly when each change happened, unless that history is painstakingly tracked in a separate table.

Event sourcing solves exactly this problem by storing a sequence of individual events instead of the current balance: AccountOpened, MoneyDeposited, MoneyWithdrawn. The current balance then follows at any time from walking through all of these events in order and summing up their individual effects, instead of being maintained as a standalone value. The major benefit is an inherently complete audit trail, plus the ability to reconstruct the historical state at any point in the past exactly, which often makes the decisive difference when debugging a real production incident.

2. Building a minimal event store yourself

At its core, an event store is an append-only table with columns for aggregate id, version number, event type, payload serialized as JSON, and a timestamp. Events are only ever appended, never modified or deleted, which is the basic prerequisite for a reliable audit trail.

The central append() method accepts an expected version number and implicitly checks, via a unique database constraint on aggregate id and version, whether another process has already stored events for the same aggregate in the meantime. The load() method returns every stored event for a given aggregate id in its original order.


<?php

declare(strict_types=1);

namespace App\EventSourcing;

interface StoredEvent
{
    public function eventType(): string;
    public function payload(): array;
}

final class ConcurrencyException extends \RuntimeException
{
}

final class EventStore
{
    public function __construct(private readonly \PDO $pdo)
    {
    }

    /**
     * @param StoredEvent[] $events
     * @throws ConcurrencyException if $expectedVersion no longer matches the stored version
     */
    public function append(string $aggregateId, int $expectedVersion, array $events): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO event_store (aggregate_id, version, event_type, payload, occurred_at)
             VALUES (:aggregate_id, :version, :event_type, :payload, :occurred_at)'
        );

        $version = $expectedVersion;
        foreach ($events as $event) {
            $version++;
            try {
                $stmt->execute([
                    'aggregate_id' => $aggregateId,
                    'version' => $version,
                    'event_type' => $event->eventType(),
                    'payload' => json_encode($event->payload()),
                    'occurred_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
                ]);
            } catch (\PDOException $e) {
                // Relies on a UNIQUE constraint on (aggregate_id, version) to
                // detect a concurrent writer that already used this version.
                throw new ConcurrencyException(
                    "Aggregate '{$aggregateId}' was modified concurrently", previous: $e
                );
            }
        }
    }

    /**
     * @return array{version:int,event_type:string,payload:array}[]
     */
    public function load(string $aggregateId): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT version, event_type, payload FROM event_store
             WHERE aggregate_id = :aggregate_id ORDER BY version ASC'
        );
        $stmt->execute(['aggregate_id' => $aggregateId]);

        return array_map(
            static fn (array $row) => [
                'version' => (int) $row['version'],
                'event_type' => $row['event_type'],
                'payload' => json_decode($row['payload'], true),
            ],
            $stmt->fetchAll(\PDO::FETCH_ASSOC)
        );
    }
}

3. Modeling aggregates and domain events as immutable objects

A domain event is modeled as an immutable, usually readonly object, such as OrderPlaced or OrderShipped, carrying exactly the payload that was relevant at the moment it occurred. An aggregate holds no state set directly from the outside, reconstructing it exclusively through an apply() method per event type that updates internal state based on that event.

New state changes only ever originate from command methods on the aggregate, such as ship(), which first check the applicable business rules and only then produce a new event. That new event is immediately applied to the in-memory state via apply() and additionally appended to a list of not-yet-persisted events, which get flushed to the event store together at the end of the request.

4. Reconstructing aggregates through replaying every event

Loading an aggregate in an event-sourced system does not mean reading a row from a table, it means loading every stored event for the given aggregate id in its original order and applying them one by one to a fresh, empty aggregate until the current state is reached.

This approach fully decouples reconstruction from the original creation and mutation methods: an aggregate created through place() and later changed through ship() can be reconstructed exactly the same way through pure event replay, with no need to call those methods again or re-check their business rules.


<?php

declare(strict_types=1);

namespace App\Order;

final class Order
{
    private string $status = 'pending';
    private int $version = 0;

    /** @var object[] */
    private array $uncommittedEvents = [];

    private function __construct(private readonly string $orderId)
    {
    }

    public static function place(string $orderId, int $totalCents): self
    {
        $order = new self($orderId);
        $order->recordThat(new OrderPlaced($orderId, $totalCents));
        return $order;
    }

    /**
     * Reconstitutes an aggregate purely by replaying its historical events,
     * without touching the "place" factory or any business rules again.
     *
     * @param array{version:int,event_type:string,payload:array}[] $storedEvents
     */
    public static function reconstituteFromEvents(string $orderId, array $storedEvents): self
    {
        $order = new self($orderId);
        foreach ($storedEvents as $stored) {
            $order->apply(self::denormalize($stored['event_type'], $stored['payload']));
            $order->version = $stored['version'];
        }
        return $order;
    }

    public function ship(): void
    {
        if ($this->status !== 'pending') {
            throw new \DomainException("Order '{$this->orderId}' cannot be shipped from status '{$this->status}'");
        }
        $this->recordThat(new OrderShipped($this->orderId));
    }

    private function recordThat(object $event): void
    {
        $this->apply($event);
        $this->uncommittedEvents[] = $event;
    }

    private function apply(object $event): void
    {
        $this->status = match ($event::class) {
            OrderPlaced::class => 'pending',
            OrderShipped::class => 'shipped',
        };
    }

    private static function denormalize(string $type, array $payload): object
    {
        return match ($type) {
            'order_placed' => new OrderPlaced($payload['order_id'], $payload['total_cents']),
            'order_shipped' => new OrderShipped($payload['order_id']),
        };
    }

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

5. Optimistic concurrency control when appending new events

When two concurrent requests load the same aggregate and both produce new events based on that same starting state, a conflict arises without additional protection: the second write would silently overwrite the first, or produce an inconsistent end state that was never actually intended.

The common fix is optimistic concurrency control: every new event carries the expected version number of the aggregate as it was at load time. The event store checks atomically when appending, usually via a unique database constraint on aggregate id and version, whether that expected version still matches what is actually stored. If it no longer matches, the store throws a ConcurrencyException, which the calling code has to handle, typically by reloading the aggregate and retrying the original operation.

6. Snapshotting: performance with a long event history

For an aggregate with tens of thousands of events, loading it grows noticeably slower on every access, because the entire history has to be replayed from the beginning every single time, just to recompute the same current state that was already known at the last load.

Snapshotting solves that problem: at regular intervals, for example every hundred events, the already computed state of an aggregate is serialized together with its version number and stored separately. On the next load, the most recent snapshot is loaded first, and only the events added since then are applied on top of it, instead of the entire history since the aggregate was created.


<?php

declare(strict_types=1);

namespace App\EventSourcing;

final class SnapshotStore
{
    public function __construct(private readonly \PDO $pdo)
    {
    }

    public function save(string $aggregateId, int $version, array $state): void
    {
        $this->pdo->prepare(
            'REPLACE INTO aggregate_snapshots (aggregate_id, version, state)
             VALUES (:aggregate_id, :version, :state)'
        )->execute([
            'aggregate_id' => $aggregateId,
            'version' => $version,
            'state' => json_encode($state),
        ]);
    }

    public function loadLatest(string $aggregateId): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT version, state FROM aggregate_snapshots WHERE aggregate_id = :aggregate_id'
        );
        $stmt->execute(['aggregate_id' => $aggregateId]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        return $row === false
            ? null
            : ['version' => (int) $row['version'], 'state' => json_decode($row['state'], true)];
    }
}

// Loading now becomes: load the latest snapshot, then only replay events
// with a version greater than the snapshot's version, instead of every
// single event since the aggregate was first created.
function loadOrder(EventStore $store, SnapshotStore $snapshots, string $orderId): array
{
    $snapshot = $snapshots->loadLatest($orderId);
    $fromVersion = $snapshot['version'] ?? 0;

    return array_filter(
        $store->load($orderId),
        static fn (array $event) => $event['version'] > $fromVersion
    );
}

7. Projections: building read models from events

Event sourcing alone does not answer questions like all open orders for a given customer efficiently, since that would require potentially reconstructing many aggregates in full every single time. That is why the same event stream is additionally used to build denormalized projections, usually asynchronously through an event listener that writes every new event into a table optimized for queries.

This separation between write and read model deliberately overlaps with the CQRS pattern, covered as its own separate topic. Event sourcing supplies the underlying data source from which any number of different projections for different read access patterns can be derived, without the write model itself needing to know anything about them.

8. Event versioning: when the schema has to change

A central problem in practice is that the structure of an event stored two years ago often no longer matches exactly what the current code expects, because business requirements have changed, for example through a newly added required field. Since already stored events must remain immutable, though, that historical schema cannot simply be adjusted after the fact.

The common fix is upcasting: a small transformation layer that automatically converts an old event format into the currently expected format on load, for example by filling a missing field with a sensible default value. For genuinely incompatible changes, a new, versioned event type is introduced instead, while the old type remains readable for historical data.

9. When event sourcing pays off, and when it does not

Event sourcing pays off especially for domains with a strong need for traceability, such as financial transactions, order processing, or contract changes, and for complex business logic where the history over time matters, not just the final state.

For simple, CRUD-heavy areas with no real business need for history, event sourcing is often just added overhead: a higher entry barrier for new team members, more expensive queries even for simple listings, and extra infrastructure for projections. The decision should therefore be made per bounded context, not as a blanket architectural choice for an entire application.

Aspect Classic CRUD persistence Event sourcing
Stored information Current state only Complete sequence of every state change
Audit trail Has to be maintained separately Inherently present
Historical state at a point in time Usually not reconstructable Possible through replay up to that point
Write speed for simple changes Direct UPDATE, very fast Extra append plus possible projection update
Read speed for complex queries Directly via a SQL query Only practical through additional projections

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

Event Sourcing in PHP: The Essentials

Core idea

Store events instead of a snapshot, the current state follows from replaying all events.

Event store

An append-only store with a version number per aggregate forms the foundation.

Scaling

Snapshotting avoids replaying the entire history on every single load.

Where it fits

Worth it for domains with a real need for traceability, not as a blanket default.

11. FAQ: Event Sourcing in PHP: The Essentials

1What is event sourcing?
An architectural pattern in which the current state of an entity is not stored directly, only the complete, immutable sequence of every event that led to that state. The current state is recomputed from those events whenever needed.
2What is an event store?
A specialized store, usually an append-only table, where events for each aggregate are recorded together with a sequential version number, the event type, and the associated payload.
3How is the current state of an aggregate determined?
Through replay: every stored event for that aggregate is loaded in its original order and applied one after another to a fresh aggregate until the current state is reached.
4Why are domain events modeled as immutable?
Because an already stored event represents a historical fact that no longer changes. Modifying it afterwards would compromise the integrity of the entire history and every reconstruction based on it.
5What is optimistic concurrency control in this context?
A mechanism that checks, when appending new events, whether the expected version number of the aggregate still matches what is actually stored, throwing an exception on a conflict instead of silently overwriting changes.
6Why is snapshotting needed?
Because for aggregates with a very long event history, loading would keep getting slower if the entire history had to be replayed from scratch every time. A snapshot provides a starting point from which only the newer events need to be applied.
7How often should a snapshot be created?
A common rule of thumb is every 50 to 200 events, depending on how expensive reconstruction is and how frequently the given aggregate is accessed. There is no universally correct value, it should be measured per aggregate type.
8How are event sourcing and CQRS related?
They are independent patterns that complement each other well: event sourcing provides the complete history as a write model, from which any number of denormalized projections can be derived as separate read models in the sense of CQRS.
9What happens when the format of an event has to change later?
Since stored events remain immutable, loading usually goes through an upcasting layer that automatically converts old event formats into the currently expected format, instead of altering the historical data itself.
10For which use cases is event sourcing a poor fit?
For simple, CRUD-heavy areas with no real business need for traceability, the extra effort for an event store, projections, and versioning is usually not justified.