DEV Community

Cover image for Event-Sourced Aggregates in PHP Without a Framework
Gabriel Anhaia
Gabriel Anhaia

Posted on

Event-Sourced Aggregates in PHP Without a Framework


You have a support ticket open. A customer swears their shopping cart had three items when they checked out, and the receipt shows one. Your carts table has a single row with total_cents and a status. It tells you what the cart is right now. It says nothing about how it got there. The two items that vanished left no trace, because an UPDATE overwrote the row that knew about them.

Event sourcing flips the storage model. You stop saving the current state and start saving the facts that produced it. The row becomes a derived view. The truth becomes an append-only log of what happened, in order, forever.

People assume this needs Prooph, EventSauce, or a broker. It doesn't. The core idea is small enough to fit in one file. It's built in plain PHP 8.4: events, apply() methods, an append-only store, rehydration from a stream, and snapshots so replay stays fast.

Events are past-tense facts

An event is something that already happened. It is immutable, it is named in the past tense, and it carries only the data the fact needs. In PHP 8.4 a readonly class says all of that in the type.

<?php

declare(strict_types=1);

interface DomainEvent {}

final readonly class CartOpened implements DomainEvent
{
    public function __construct(public string $customerId) {}
}

final readonly class ItemAdded implements DomainEvent
{
    public function __construct(
        public string $sku,
        public int $qty,
        public int $priceCents,
    ) {}
}

final readonly class ItemRemoved implements DomainEvent
{
    public function __construct(public string $sku) {}
}

final readonly class CheckedOut implements DomainEvent {}
Enter fullscreen mode Exit fullscreen mode

No IDs on the events, no timestamps yet. Those are envelope concerns — the store wraps each event with a sequence number and a recorded-at time when it persists them. The event itself stays a pure fact about the domain.

The aggregate records, then applies

A command method on the aggregate does two things. It checks the invariant, and if the invariant holds, it records an event. It does not mutate state directly. Every state change goes through a single private apply() method, and that method is the only place a field is ever written.

<?php

declare(strict_types=1);

final class Cart
{
    private string $customerId = '';
    private array $items = [];       // sku => [qty, priceCents]
    private bool $checkedOut = false;

    /** @var DomainEvent[] */
    private array $pending = [];

    private function __construct() {}

    public static function open(string $customerId): self
    {
        $cart = new self();
        $cart->record(new CartOpened($customerId));
        return $cart;
    }

    public function addItem(string $sku, int $qty, int $cents): void
    {
        if ($this->checkedOut) {
            throw new \DomainException('cart is closed');
        }
        $this->record(new ItemAdded($sku, $qty, $cents));
    }

    public function removeItem(string $sku): void
    {
        if (!isset($this->items[$sku])) {
            throw new \DomainException('no such item');
        }
        $this->record(new ItemRemoved($sku));
    }

    public function checkout(): void
    {
        if ($this->items === []) {
            throw new \DomainException('cart is empty');
        }
        $this->record(new CheckedOut());
    }
}
Enter fullscreen mode Exit fullscreen mode

The invariant lives in the command method: you cannot check out an empty cart, you cannot add to a closed one. The command decides whether the fact is allowed. Once it decides yes, record() takes over.

record() writes to the buffer and folds

record() appends the event to a pending buffer — those are the new facts this aggregate wants persisted — and then calls apply() to fold the event into the in-memory state. Rehydration will call the same apply() without the buffer. That is the trick that keeps replay and live behavior identical.

    private function record(DomainEvent $event): void
    {
        $this->pending[] = $event;
        $this->apply($event);
    }

    private function apply(DomainEvent $event): void
    {
        match (true) {
            $event instanceof CartOpened =>
                $this->customerId = $event->customerId,
            $event instanceof ItemAdded =>
                $this->items[$event->sku] =
                    [$event->qty, $event->priceCents],
            $event instanceof ItemRemoved =>
                $this->onItemRemoved($event->sku),
            $event instanceof CheckedOut =>
                $this->checkedOut = true,
            default => null,
        };
    }

    private function onItemRemoved(string $sku): void
    {
        unset($this->items[$sku]);
    }

    /** @return DomainEvent[] */
    public function releaseEvents(): array
    {
        $events = $this->pending;
        $this->pending = [];
        return $events;
    }
Enter fullscreen mode Exit fullscreen mode

apply() never validates. It trusts the event, because a recorded event is a fact that already passed its command's invariant. Validating again on replay would reject history that was legal when it happened, which is how you brick a system after a rule change.

Rebuilding state is a fold over the stream

To load a cart, you read its events in order and replay them through apply(). State is a left fold over the event stream, starting from an empty aggregate. This is the whole read path.

    /** @param DomainEvent[] $stream */
    public static function fromStream(array $stream): self
    {
        $cart = new self();
        foreach ($stream as $event) {
            $cart->apply($event);
        }
        return $cart;
    }
Enter fullscreen mode Exit fullscreen mode

No pending gets populated here, because fromStream calls apply() directly, never record(). You reconstruct the exact state the aggregate had, without emitting new events. Same folding logic as the live path, one source of truth for how each fact changes state.

The append-only store

The store only ever inserts. There is no UPDATE, no DELETE. Each row is one event with a per-aggregate sequence number, and a unique index on (aggregate_id, version) gives you optimistic concurrency for free — two writers racing on the same version collide on the index.

CREATE TABLE events (
    aggregate_id  CHAR(36)     NOT NULL,
    version       INT UNSIGNED NOT NULL,
    event_type    VARCHAR(120) NOT NULL,
    payload       JSON         NOT NULL,
    recorded_at   DATETIME(6)  NOT NULL,
    PRIMARY KEY (aggregate_id, version)
);
Enter fullscreen mode Exit fullscreen mode
<?php

declare(strict_types=1);

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

    /** @param DomainEvent[] $events */
    public function append(
        string $id,
        int $fromVersion,
        array $events,
    ): void {
        $stmt = $this->pdo->prepare(
            'INSERT INTO events
             (aggregate_id, version, event_type,
              payload, recorded_at)
             VALUES (?, ?, ?, ?, ?)'
        );
        $version = $fromVersion;
        foreach ($events as $event) {
            $stmt->execute([
                $id,
                ++$version,
                $event::class,
                json_encode($event),
                (new \DateTimeImmutable())
                    ->format('Y-m-d H:i:s.u'),
            ]);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

$fromVersion is the version you loaded. If someone else appended in the meantime, your ++$version collides with their row on the primary key and the insert throws. You caught a lost update without a lock. Wrap the loop in a transaction and the batch is all-or-nothing.

Reading back is a SELECT ... ORDER BY version, decoding each JSON payload into its event class. event_type holds the class name, so a small map from type string to constructor rebuilds each readonly object.

Snapshots keep replay cheap

Folding ten events is instant. Folding forty thousand is not. That happens with a cart some bot has poked for months, or a long-lived account aggregate. A snapshot is a cached fold: the aggregate state at version N, stored so you replay only the events after N.

<?php

declare(strict_types=1);

final readonly class Snapshot
{
    public function __construct(
        public int $version,
        public array $state,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

You add two methods to the aggregate: one that exports its state as a plain array, one that restores from that array. Load becomes: fetch the latest snapshot, restore it, then replay only events with version > snapshot.version. The snapshot is disposable. Delete every snapshot and the events still rebuild the exact same state, slower. That is the property that matters — the log is the source of truth, the snapshot is only an optimization you can throw away and regenerate.

A common cadence is one snapshot every 50 or 100 events, written by a background job that reads the stream and folds it. Nothing in the write path blocks on it.

What you get for the extra work

You get an audit log you did not have to build, because the log is the storage. You get time travel: fold up to version 12 and you see the cart as it stood then. You get new read models for free — point a fresh projector at the stream and build a report that nobody planned for when the events were written, without touching a single write.

The cost is real. Queries over current state need projections, because SELECT * FROM carts WHERE status = 'open' no longer exists. Schema evolution moves to event versioning — an old ItemAdded without a priceCents field needs an upcaster. Event sourcing earns its place where history is the product: ledgers, order lifecycles, anything an auditor or a support agent will ask "what happened, and when." For a settings table, keep the boring row.

Notice what the aggregate never imported. No PDO, no framework, no broker. Cart is pure domain logic that records facts; PdoEventStore is an adapter that persists them. That seam is the point — the store is infrastructure at the edge, and the folding rules that define what your cart is stay in the domain, testable with an array and zero I/O. Keeping the framework out of the aggregate and the persistence concern behind a port is exactly the discipline Decoupled PHP builds chapter by chapter, so the log outlives whatever store you started with.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)