Implementing CQRS and Event Sourcing in Modern PHP Applications

CQRS and Event Sourcing in PHP: The Parts Nobody Warns You About

CQRS and event sourcing get discussed as a single idea, which is the first source of confusion. They are separate patterns that happen to combine well. You can adopt one without the other, and most teams that succeed with either start by adopting only the first.

CQRS means the model you write through and the model you read through are different objects. Event sourcing means you persist the sequence of changes rather than the current state, and derive current state by replaying them. Conflating the two leads teams to take on the operational weight of an event store when all they needed was a separate read model.

What follows is a working implementation of both in PHP 8.4, with attention to the parts that cause production incidents: concurrent writes, projection lag, and the replay cost that creeps up over years.

CQRS on Its Own

The cheapest useful version of CQRS involves no new infrastructure. Writes go through a domain model that enforces invariants; reads bypass it entirely and query whatever shape the screen needs.

<?php

declare(strict_types=1);

// Write side: an object whose job is protecting invariants.
final class SubscriptionService
{
    public function __construct(private readonly PDO $db) {}

    public function cancel(string $subscriptionId, string $reason): void
    {
        $subscription = $this->repository->get($subscriptionId);
        $subscription->cancel($reason);          // business rules live here
        $this->repository->save($subscription);
    }
}

// Read side: a query returning exactly what the view renders.
// No entities, no lazy loading, no N+1 - just the columns needed.
final class SubscriptionListQuery
{
    public function __construct(private readonly PDO $db) {}

    /** @return list<array{id:string,plan:string,status:string,renews_at:?string}> */
    public function forAccount(string $accountId, int $limit = 50): array
    {
        $stmt = $this->db->prepare(
            'SELECT s.id, p.name AS plan, s.status, s.renews_at
               FROM subscriptions s
               JOIN plans p ON p.id = s.plan_id
              WHERE s.account_id = :account
              ORDER BY s.created_at DESC
              LIMIT :limit'
        );
        $stmt->bindValue(':account', $accountId);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }
}

That is the entire pattern at its base. The payoff is that read performance stops being hostage to a write model shaped around consistency rules. If this solves your problem, stop here — you have captured most of the value for almost none of the cost.

Modelling Events

Event sourcing starts when you stop storing “this subscription is cancelled” and start storing “this subscription was cancelled at this time for this reason”. Events are immutable facts in the past tense, which maps cleanly onto readonly classes.

<?php

declare(strict_types=1);

interface DomainEvent
{
    public function aggregateId(): string;
    public function occurredAt(): DateTimeImmutable;
    /** @return array<string,mixed> */
    public function payload(): array;
}

// readonly classes (PHP 8.2+) make immutability structural rather than a convention.
final readonly class SubscriptionCancelled implements DomainEvent
{
    public function __construct(
        private string $subscriptionId,
        private string $reason,
        private CancellationSource $source,
        private DateTimeImmutable $occurredAt = new DateTimeImmutable(),
    ) {}

    public function aggregateId(): string { return $this->subscriptionId; }
    public function occurredAt(): DateTimeImmutable { return $this->occurredAt; }

    public function payload(): array
    {
        return [
            'reason' => $this->reason,
            'source' => $this->source->value,
        ];
    }

    /** @param array<string,mixed> $payload */
    public static function fromPayload(string $id, array $payload, DateTimeImmutable $at): self
    {
        return new self(
            $id,
            $payload['reason'],
            CancellationSource::from($payload['source']),
            $at,
        );
    }
}

enum CancellationSource: string
{
    case Customer       = 'customer';
    case PaymentFailure = 'payment_failure';
    case Administrator  = 'administrator';
}

Backed enums are the right tool for the closed sets that appear throughout event payloads, and they serialise to stable strings — which matters when the value has to survive in a database for years. The broader case for modelling domain concepts this way is covered in the guide to PHP enums in domain-driven design.

Every event needs a fromPayload counterpart because events are written once and read back thousands of times during replay. Serialisation is not an afterthought here — it is half the contract.

The Event Store Schema

The storage layer is simpler than most people expect. A single append-only table does the job, and one constraint on it provides the concurrency control the whole system depends on.

CREATE TABLE event_store (
    sequence       BIGSERIAL    PRIMARY KEY,
    aggregate_id   UUID         NOT NULL,
    aggregate_type TEXT         NOT NULL,
    version        INTEGER      NOT NULL,
    event_type     TEXT         NOT NULL,
    payload        JSONB        NOT NULL,
    metadata       JSONB        NOT NULL DEFAULT '{}'::jsonb,
    occurred_at    TIMESTAMPTZ  NOT NULL DEFAULT now(),

    -- This is the optimistic concurrency control for the entire system.
    -- Two writers who both loaded version 7 cannot both append version 8.
    CONSTRAINT uniq_aggregate_version UNIQUE (aggregate_id, version)
);

-- Replaying one aggregate: the hot path for every write.
CREATE INDEX idx_event_store_aggregate
    ON event_store (aggregate_id, version);

-- Projectors tail the global ordering by sequence.
CREATE INDEX idx_event_store_sequence
    ON event_store (sequence);

That UNIQUE (aggregate_id, version) constraint deserves attention. It is not bookkeeping — it is the mechanism that makes concurrent writes safe without table locks. Two requests that both read an aggregate at version 7 will both try to insert version 8; the database rejects the second, and the application retries against fresh state.

Aggregates That Rebuild Themselves

An event-sourced aggregate holds no persistent state of its own. It is reconstructed by folding its event history, and any command it accepts produces new events rather than mutating fields directly.

<?php

declare(strict_types=1);

final class Subscription
{
    /** @var list<DomainEvent> */
    private array $pendingEvents = [];

    private SubscriptionStatus $status = SubscriptionStatus::Pending;
    private ?DateTimeImmutable $cancelledAt = null;
    private int $version = 0;

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

    /** Rebuild from history. No validation - these facts already happened. */
    public static function replay(string $id, iterable $events): self
    {
        $subscription = new self($id);
        foreach ($events as $event) {
            $subscription->apply($event);
            $subscription->version++;
        }
        return $subscription;
    }

    /** Commands validate, then record. They never mutate state directly. */
    public function cancel(string $reason, CancellationSource $source): void
    {
        if ($this->status === SubscriptionStatus::Cancelled) {
            throw new DomainException('Subscription is already cancelled');
        }
        if ($this->status === SubscriptionStatus::Pending) {
            throw new DomainException('Cannot cancel a subscription that never activated');
        }

        $this->record(new SubscriptionCancelled($this->id, $reason, $source));
    }

    private function record(DomainEvent $event): void
    {
        $this->apply($event);                  // state moves forward immediately
        $this->pendingEvents[] = $event;       // and is queued for persistence
    }

    /** The only place state changes. Must be total and side-effect free. */
    private function apply(DomainEvent $event): void
    {
        match (true) {
            $event instanceof SubscriptionActivated =>
                $this->status = SubscriptionStatus::Active,

            $event instanceof SubscriptionCancelled => (function () use ($event) {
                $this->status      = SubscriptionStatus::Cancelled;
                $this->cancelledAt = $event->occurredAt();
            })(),

            // Unknown events must not throw - an older deployment will see
            // events written by a newer one during a rolling release.
            default => null,
        };
    }

    /** @return list<DomainEvent> */
    public function releaseEvents(): array
    {
        $events = $this->pendingEvents;
        $this->pendingEvents = [];
        return $events;
    }

    public function version(): int { return $this->version; }
}

enum SubscriptionStatus: string
{
    case Pending   = 'pending';
    case Active    = 'active';
    case Cancelled = 'cancelled';
}

The default => null branch in apply() is a deployment detail that only reveals itself the hard way. During a rolling release, old application instances read events emitted by new ones. If an unrecognised event type throws, half your fleet starts failing on aggregates the other half just touched.

Handling the Write Conflict

The repository turns the unique constraint violation into something the application layer can act on.

<?php

final class ConcurrencyConflict extends RuntimeException {}

final class EventStoreRepository
{
    public function __construct(
        private readonly PDO $db,
        private readonly EventSerializer $serializer,
    ) {}

    public function load(string $aggregateId): Subscription
    {
        $stmt = $this->db->prepare(
            'SELECT event_type, payload, occurred_at
               FROM event_store
              WHERE aggregate_id = :id
              ORDER BY version ASC'
        );
        $stmt->execute([':id' => $aggregateId]);

        // Streaming keeps memory flat on aggregates with long histories.
        $events = (function () use ($stmt, $aggregateId): Generator {
            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
                yield $this->serializer->deserialize($aggregateId, $row);
            }
        })();

        return Subscription::replay($aggregateId, $events);
    }

    /** @throws ConcurrencyConflict when another writer won the race */
    public function save(Subscription $aggregate, int $expectedVersion): void
    {
        $events = $aggregate->releaseEvents();
        if ($events === []) {
            return;
        }

        $this->db->beginTransaction();
        try {
            $stmt = $this->db->prepare(
                'INSERT INTO event_store
                    (aggregate_id, aggregate_type, version, event_type, payload, occurred_at)
                 VALUES (:id, :type, :version, :event, :payload::jsonb, :at)'
            );

            $version = $expectedVersion;
            foreach ($events as $event) {
                $stmt->execute([
                    ':id'      => $event->aggregateId(),
                    ':type'    => 'subscription',
                    ':version' => ++$version,
                    ':event'   => $event::class,
                    ':payload' => json_encode($event->payload(), JSON_THROW_ON_ERROR),
                    ':at'      => $event->occurredAt()->format(DateTimeInterface::RFC3339_EXTENDED),
                ]);
            }

            $this->db->commit();
        } catch (PDOException $e) {
            $this->db->rollBack();

            // 23505 = unique_violation. Someone appended the same version first.
            if ($e->getCode() === '23505') {
                throw new ConcurrencyConflict(
                    "Aggregate {$aggregate->version()} was modified concurrently", 0, $e
                );
            }
            throw $e;
        }
    }
}

Callers retry the whole command — reload, re-validate, re-emit — rather than replaying the events they already built. That distinction matters: the business rules must run again against current state, because the conflicting write may have made the command invalid. PostgreSQL’s transaction isolation documentation explains why this approach holds under concurrent load where a naive read-modify-write does not.

Projections and the Lag You Have to Design For

Read models are built by projectors that consume the event stream in sequence order and write denormalised tables. Each projector tracks its own position, so it can resume after a crash and be rebuilt from zero independently.

<?php

final class SubscriptionListProjector
{
    private const NAME = 'subscription_list_v3';   // version in the name enables rebuilds

    public function __construct(private readonly PDO $db) {}

    public function catchUp(int $batchSize = 500): int
    {
        $position = $this->currentPosition();

        $stmt = $this->db->prepare(
            'SELECT sequence, aggregate_id, event_type, payload, occurred_at
               FROM event_store
              WHERE sequence > :position
              ORDER BY sequence ASC
              LIMIT :limit'
        );
        $stmt->bindValue(':position', $position, PDO::PARAM_INT);
        $stmt->bindValue(':limit', $batchSize, PDO::PARAM_INT);
        $stmt->execute();

        $processed = 0;
        $this->db->beginTransaction();

        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $this->handle($row);
            $position = (int) $row['sequence'];
            $processed++;
        }

        // Position and projected rows commit together, so a crash mid-batch
        // replays the batch rather than skipping it. Handlers must be idempotent.
        $this->savePosition($position);
        $this->db->commit();

        return $processed;
    }

    private function handle(array $row): void
    {
        match ($row['event_type']) {
            SubscriptionCancelled::class => $this->db->prepare(
                'UPDATE subscription_list
                    SET status = :status, cancelled_at = :at
                  WHERE id = :id'
            )->execute([
                ':status' => 'cancelled',
                ':at'     => $row['occurred_at'],
                ':id'     => $row['aggregate_id'],
            ]),
            default => null,
        };
    }
}

Everything downstream of the event store is eventually consistent. A user who cancels a subscription and is immediately redirected to a list rendered from the projection may see it still active. This is not a bug to be fixed later; it is the defining property of the architecture, and the interface has to account for it — return the command result directly, or have the client poll until the projection catches up.

Running projectors as long-lived workers rather than cron jobs keeps lag in the low milliseconds. The worker patterns for that are covered in the guide to event-driven PHP with RoadRunner and RabbitMQ.

When Replay Gets Slow

Rebuilding an aggregate from 40 events is instant. From 40,000, it is not. Two mitigations, in order of preference:

Fix the aggregate boundary first. An aggregate accumulating tens of thousands of events is usually modelling something too large. A Subscription with a few hundred lifecycle events is healthy; an Account that records every page view is a design error no amount of optimisation will rescue.

Then add snapshots. Periodically serialise aggregate state and replay only the events after it.

CREATE TABLE aggregate_snapshots (
    aggregate_id UUID        PRIMARY KEY,
    version      INTEGER     NOT NULL,
    state        JSONB       NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Load path becomes: read snapshot, then only newer events.
SELECT event_type, payload, occurred_at
  FROM event_store
 WHERE aggregate_id = $1
   AND version > $2          -- the snapshot's version
 ORDER BY version ASC;

Snapshots are a cache, and they must be treated as disposable. If a snapshot’s format changes, delete every snapshot and let them regenerate from events. Never let a snapshot become the only copy of state — the moment that happens you have a mutable-state system with extra steps.

The Costs, Stated Plainly

Event sourcing is a real commitment, and the honest accounting looks like this:

ConcernTraditional CRUDEvent sourced
Fix bad dataUPDATE one rowAppend a corrective event; never edit history
Schema changeMigrationEvent upcasting plus projection rebuild
Ad-hoc reportingQuery the tableBuild a projection first
Debugging “how did this happen?”Guess from logsRead the event stream — the actual answer
Onboarding a developerDaysWeeks
Storage growthBounded by entity countGrows forever
Deleting personal dataDELETEGenuinely hard — see below

That last row is worth pausing on. An append-only store and a legal obligation to erase personal data are in direct tension. The workable answer is crypto-shredding: encrypt each subject’s personal fields with a per-subject key, store keys separately, and discard the key on an erasure request. The events remain, and their personal payload becomes permanently unreadable. Design this in from the start — retrofitting it across a live event store is genuinely painful.

Reach for event sourcing when the history is the product: ledgers, audit-critical workflows, anything where “what did this look like on the 14th” is a question the business actually asks. For a CRUD admin panel, it is expensive ceremony. Start with CQRS, keep the domain model clean, and add the event store only when something forces the issue.

editor's pick

latest video

news via inbox

Nulla turp dis cursus. Integer liberos  euismod pretium faucibua

Leave A Comment