Back to blog

Architecture

Building Event Sourcing Systems in PHP: A Step-by-Step Implementation

Implements an event store, aggregate roots, projectors, and process managers for an event-sourced PHP application from scratch.

  • PHP
  • Event Sourcing
  • Architecture
  • CQRS
  • Domain Events

SEO Metadata

SEO Title Options

  1. Building Event Sourcing Systems in PHP: A Step-by-Step
  2. PHP Architecture: Practical 2026 Guide
  3. Architecture Playbook: PHP Architecture

Meta Description Options

  1. Learn PHP Architecture with a practical Architecture framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
  2. Implements an event store, aggregate roots, projectors, and process managers for an event-sourced PHP application from scratch.

URL Slug

building-event-sourcing-systems-php-step-by-step-implementation

Focus Keyword

PHP Architecture

Additional LSI Keywords

  • Architecture
  • PHP
  • Event Sourcing
  • CQRS
  • Domain Events
  • Building Event Sourcing Systems in PHP: A Step-by-Step Implementation
  • production checklist
  • implementation guide
  • best practices
  • architecture decisions
  • testing strategy
  • performance impact

Table of Contents

Article overview

PHP Architecture is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.

The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.

Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.

Key Takeaways

  • PHP Architecture should be evaluated as a production decision, not only as a syntax or tooling choice.
  • The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
  • Search visibility improves when practical depth, structured answers, and expert examples live on the same page.

[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: PHP Architecture expert guide for Architecture]

What PHP Architecture means

PHP Architecture means applying architecture knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.

This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.

Why it matters now

The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.

For architecture topics, the strongest content now has three layers:

  • a clear answer for fast scanning
  • a practical framework for implementation
  • expert context that explains what breaks later

That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.

Implementation framework

Use this framework before adopting the approach described in this article.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. Revisit the decision after real usage exposes edge cases.

The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.

[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: PHP Architecture implementation framework]

Practical comparison

Decision areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes the decision reversible

This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.

Expert workflow

Expert tip: "Treat PHP Architecture as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."

A useful workflow is simple:

  • Start with the smallest working example.
  • Add the constraints that exist in your real project.
  • Remove anything that only demonstrates cleverness.
  • Write down the failure modes.
  • Add links to related decisions so future readers can navigate the topic cluster.

That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.

Common mistakes

Mistake 1: Copying a pattern without its context

A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.

Before copying the pattern, ask what assumption made it safe in the original example.

Mistake 2: Putting business logic in the wrong layer

This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.

Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.

Mistake 3: Optimizing for novelty instead of maintainability

Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.

Use the option that makes the next production incident easier to understand.

Mistake 4: Publishing without a measurement plan

If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.

[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: PHP Architecture common mistakes]

Image placeholders

  • [IMAGE: A concept diagram for PHP Architecture with input, decision boundary, implementation, tests, and production feedback. Alt: PHP Architecture concept diagram]
  • [IMAGE: A mobile screenshot-style checklist for Building Event Sourcing Systems in PHP: A Step-by-Step Implementation. Alt: PHP Architecture mobile checklist]
  • [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: PHP Architecture comparison table]

Video placeholder

[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for PHP Architecture.]

Internal linking opportunities

Original Technical Deep Dive

Use event sourcing for history, not fashion

Event sourcing stores state changes as an append-only sequence of events. The event stream becomes the source of truth. Current state is rebuilt by replaying those events.

That gives you useful capabilities:

  • full audit history;
  • temporal reconstruction;
  • replayable read models;
  • append-only writes;
  • explicit business facts;
  • integration events from the same source of truth.

It also adds real cost:

  • schema evolution is harder;
  • projections can lag;
  • idempotency becomes mandatory;
  • privacy rules must be designed up front;
  • debugging moves from rows to event timelines;
  • most queries need read models.

Do not event-source generic CRUD. Use it where the history is part of the domain: orders, payments, subscriptions, inventory, ledgers, approvals, logistics, bookings, and workflows with long-lived business state.

This guide builds a small ordering system from scratch in PHP.

The target shape

The write path:

Command
  -> command handler
  -> load aggregate events
  -> rehydrate aggregate
  -> execute business method
  -> append new events with expected version

The read path:

Event store
  -> projector
  -> read model table
  -> query handler

Side effects:

Event store
  -> process manager
  -> new commands
  -> other aggregates or external workflows

Keep these boundaries separate. Aggregates enforce invariants. Event stores persist facts. Projectors build read models. Process managers coordinate follow-up work.

Create the event store table

Start with a relational table. PostgreSQL is used in the examples, but the same design works in MySQL with syntax changes.

create table event_store (
    id bigserial primary key,
    event_id char(36) not null unique,
    stream_name varchar(255) not null,
    stream_version integer not null,
    event_type varchar(255) not null,
    event_version integer not null,
    payload_json text not null,
    metadata_json text not null,
    recorded_at timestamptz not null default now(),
    unique (stream_name, stream_version)
);

create index event_store_stream_name_id_idx
    on event_store (stream_name, id);

create index event_store_recorded_at_idx
    on event_store (recorded_at);

Important fields:

FieldPurpose
idGlobal event position for projections.
event_idStable event UUID for deduplication.
stream_nameAggregate stream, such as order-123.
stream_versionVersion inside one aggregate stream.
event_typeStable public event name, not necessarily PHP class name.
event_versionSchema version for that event type.
payload_jsonBusiness data.
metadata_jsonCorrelation ID, causation ID, user ID, request ID.

The unique (stream_name, stream_version) constraint is the safety net for optimistic concurrency. Two writers cannot append different events to the same version of one stream.

Define event contracts

Domain events should be past-tense business facts:

OrderPlaced
OrderLineAdded
OrderConfirmed
PaymentCaptured
ShipmentRequested

Avoid weak events:

OrderUpdated
StatusChanged
DataSaved

Start with a small interface:

<?php

declare(strict_types=1);

namespace App\EventSourcing;

use DateTimeImmutable;

interface DomainEvent
{
    public function aggregateId(): string;

    public function occurredAt(): DateTimeImmutable;

    /**
     * @return array<string, mixed>
     */
    public function toPayload(): array;

    public function type(): string;

    public function version(): int;
}

Example event:

<?php

declare(strict_types=1);

namespace App\Domain\Order\Event;

use App\EventSourcing\DomainEvent;
use DateTimeImmutable;

final readonly class OrderPlaced implements DomainEvent
{
    public function __construct(
        public string $orderId,
        public string $customerId,
        public DateTimeImmutable $placedAt,
    ) {}

    public function aggregateId(): string
    {
        return $this->orderId;
    }

    public function occurredAt(): DateTimeImmutable
    {
        return $this->placedAt;
    }

    public function type(): string
    {
        return 'order.placed';
    }

    public function version(): int
    {
        return 1;
    }

    /**
     * @return array<string, mixed>
     */
    public function toPayload(): array
    {
        return [
            'order_id' => $this->orderId,
            'customer_id' => $this->customerId,
            'placed_at' => $this->placedAt->format(DATE_ATOM),
        ];
    }

    /**
     * @param array<string, mixed> $payload
     */
    public static function fromPayload(array $payload): self
    {
        return new self(
            orderId: (string) $payload['order_id'],
            customerId: (string) $payload['customer_id'],
            placedAt: new DateTimeImmutable((string) $payload['placed_at']),
        );
    }
}

Add two more events:

<?php

declare(strict_types=1);

namespace App\Domain\Order\Event;

use App\EventSourcing\DomainEvent;
use DateTimeImmutable;

final readonly class OrderLineAdded implements DomainEvent
{
    public function __construct(
        public string $orderId,
        public string $productId,
        public int $quantity,
        public int $unitPriceCents,
        public DateTimeImmutable $addedAt,
    ) {}

    public function aggregateId(): string
    {
        return $this->orderId;
    }

    public function occurredAt(): DateTimeImmutable
    {
        return $this->addedAt;
    }

    public function type(): string
    {
        return 'order.line_added';
    }

    public function version(): int
    {
        return 1;
    }

    /**
     * @return array<string, mixed>
     */
    public function toPayload(): array
    {
        return [
            'order_id' => $this->orderId,
            'product_id' => $this->productId,
            'quantity' => $this->quantity,
            'unit_price_cents' => $this->unitPriceCents,
            'added_at' => $this->addedAt->format(DATE_ATOM),
        ];
    }

    /**
     * @param array<string, mixed> $payload
     */
    public static function fromPayload(array $payload): self
    {
        return new self(
            orderId: (string) $payload['order_id'],
            productId: (string) $payload['product_id'],
            quantity: (int) $payload['quantity'],
            unitPriceCents: (int) $payload['unit_price_cents'],
            addedAt: new DateTimeImmutable((string) $payload['added_at']),
        );
    }
}
<?php

declare(strict_types=1);

namespace App\Domain\Order\Event;

use App\EventSourcing\DomainEvent;
use DateTimeImmutable;

final readonly class OrderConfirmed implements DomainEvent
{
    public function __construct(
        public string $orderId,
        public int $totalCents,
        public DateTimeImmutable $confirmedAt,
    ) {}

    public function aggregateId(): string
    {
        return $this->orderId;
    }

    public function occurredAt(): DateTimeImmutable
    {
        return $this->confirmedAt;
    }

    public function type(): string
    {
        return 'order.confirmed';
    }

    public function version(): int
    {
        return 1;
    }

    /**
     * @return array<string, mixed>
     */
    public function toPayload(): array
    {
        return [
            'order_id' => $this->orderId,
            'total_cents' => $this->totalCents,
            'confirmed_at' => $this->confirmedAt->format(DATE_ATOM),
        ];
    }

    /**
     * @param array<string, mixed> $payload
     */
    public static function fromPayload(array $payload): self
    {
        return new self(
            orderId: (string) $payload['order_id'],
            totalCents: (int) $payload['total_cents'],
            confirmedAt: new DateTimeImmutable((string) $payload['confirmed_at']),
        );
    }
}

The event type strings are storage contracts. Do not change them casually when renaming PHP classes.

Serialize events

Keep serialization in one place:

<?php

declare(strict_types=1);

namespace App\EventSourcing;

use App\Domain\Order\Event\OrderConfirmed;
use App\Domain\Order\Event\OrderLineAdded;
use App\Domain\Order\Event\OrderPlaced;
use RuntimeException;

final class EventSerializer
{
    /**
     * @return array{type: string, version: int, payload: string}
     */
    public function serialize(DomainEvent $event): array
    {
        $payload = json_encode($event->toPayload(), JSON_THROW_ON_ERROR);

        return [
            'type' => $event->type(),
            'version' => $event->version(),
            'payload' => $payload,
        ];
    }

    public function deserialize(string $type, string $payloadJson): DomainEvent
    {
        $payload = json_decode($payloadJson, true, flags: JSON_THROW_ON_ERROR);

        if (! is_array($payload)) {
            throw new RuntimeException(sprintf('Invalid payload for event [%s].', $type));
        }

        return match ($type) {
            'order.placed' => OrderPlaced::fromPayload($payload),
            'order.line_added' => OrderLineAdded::fromPayload($payload),
            'order.confirmed' => OrderConfirmed::fromPayload($payload),
            default => throw new RuntimeException(sprintf('Unknown event type [%s].', $type)),
        };
    }
}

This is intentionally boring. You can add upcasters later, but do not start with a reflection-based serializer that hides your storage contract.

Represent stored events

Projectors and process managers need the event plus event-store metadata:

<?php

declare(strict_types=1);

namespace App\EventSourcing;

final readonly class StoredEvent
{
    /**
     * @param array<string, mixed> $metadata
     */
    public function __construct(
        public int $id,
        public string $eventId,
        public string $streamName,
        public int $streamVersion,
        public DomainEvent $event,
        public array $metadata,
    ) {}
}

[IMAGE: Supporting visual 1 for Building Event Sourcing Systems in PHP: A Step-by-Step Implementation, showing PHP Architecture decisions, examples, and PHP, Event Sourcing, Architecture. Alt: PHP Architecture building-event-sourcing-systems-php-step-by-step-implementation visual 1]

[IMAGE: Supporting visual 1 for Building Event Sourcing Systems in PHP: A Step-by-Step Implementation, showing PHP Architecture decisions, examples, and PHP, Event Sourcing, Architecture. Alt: PHP Architecture building-event-sourcing-systems-php-step-by-step-implementation visual 1]

The aggregate only needs the domain events. Consumers usually need the global id, metadata, and stream version too.

Implement append and load

The event store appends events with an expected stream version:

<?php

declare(strict_types=1);

namespace App\EventSourcing;

use PDO;
use Throwable;

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

    /**
     * @param list<DomainEvent> $events
     * @param array<string, mixed> $metadata
     */
    public function append(string $streamName, int $expectedVersion, array $events, array $metadata = []): void
    {
        if ($events === []) {
            return;
        }

        $this->pdo->beginTransaction();

        try {
            $currentVersion = $this->currentVersion($streamName);

            if ($currentVersion !== $expectedVersion) {
                throw ConcurrencyException::forStream($streamName, $expectedVersion, $currentVersion);
            }

            $statement = $this->pdo->prepare(
                'insert into event_store
                    (event_id, stream_name, stream_version, event_type, event_version, payload_json, metadata_json)
                 values
                    (:event_id, :stream_name, :stream_version, :event_type, :event_version, :payload_json, :metadata_json)'
            );

            foreach ($events as $index => $event) {
                $serialized = $this->serializer->serialize($event);

                $statement->execute([
                    'event_id' => self::uuid(),
                    'stream_name' => $streamName,
                    'stream_version' => $expectedVersion + $index + 1,
                    'event_type' => $serialized['type'],
                    'event_version' => $serialized['version'],
                    'payload_json' => $serialized['payload'],
                    'metadata_json' => json_encode($metadata, JSON_THROW_ON_ERROR),
                ]);
            }

            $this->pdo->commit();
        } catch (Throwable $exception) {
            $this->pdo->rollBack();

            throw $exception;
        }
    }

    /**
     * @return list<DomainEvent>
     */
    public function load(string $streamName): array
    {
        $statement = $this->pdo->prepare(
            'select event_type, payload_json
             from event_store
             where stream_name = :stream_name
             order by stream_version asc'
        );

        $statement->execute(['stream_name' => $streamName]);

        $events = [];

        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $events[] = $this->serializer->deserialize(
                type: (string) $row['event_type'],
                payloadJson: (string) $row['payload_json'],
            );
        }

        return $events;
    }

    /**
     * @return list<StoredEvent>
     */
    public function readAllAfter(int $lastSeenId, int $limit = 100): array
    {
        $statement = $this->pdo->prepare(
            'select id, event_id, stream_name, stream_version, event_type, payload_json, metadata_json
             from event_store
             where id > :last_seen_id
             order by id asc
             limit :limit'
        );

        $statement->bindValue('last_seen_id', $lastSeenId, PDO::PARAM_INT);
        $statement->bindValue('limit', $limit, PDO::PARAM_INT);
        $statement->execute();

        $events = [];

        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $metadata = json_decode((string) $row['metadata_json'], true, flags: JSON_THROW_ON_ERROR);

            $events[] = new StoredEvent(
                id: (int) $row['id'],
                eventId: (string) $row['event_id'],
                streamName: (string) $row['stream_name'],
                streamVersion: (int) $row['stream_version'],
                event: $this->serializer->deserialize(
                    type: (string) $row['event_type'],
                    payloadJson: (string) $row['payload_json'],
                ),
                metadata: is_array($metadata) ? $metadata : [],
            );
        }

        return $events;
    }

    private function currentVersion(string $streamName): int
    {
        $statement = $this->pdo->prepare(
            'select coalesce(max(stream_version), 0)
             from event_store
             where stream_name = :stream_name'
        );

        $statement->execute(['stream_name' => $streamName]);

        return (int) $statement->fetchColumn();
    }

    private static function uuid(): string
    {
        $bytes = random_bytes(16);
        $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
        $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);

        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
    }
}

Concurrency exception:

<?php

declare(strict_types=1);

namespace App\EventSourcing;

use RuntimeException;

final class ConcurrencyException extends RuntimeException
{
    public static function forStream(string $streamName, int $expectedVersion, int $actualVersion): self
    {
        return new self(sprintf(
            'Stream [%s] expected version [%d], actual version [%d].',
            $streamName,
            $expectedVersion,
            $actualVersion,
        ));
    }
}

In production, also catch database unique-constraint violations and convert them to ConcurrencyException. The database constraint is what protects you when two transactions race between version check and insert.

Build the aggregate root

The aggregate records new events and can be rehydrated from old events:

<?php

declare(strict_types=1);

namespace App\EventSourcing;

use RuntimeException;

abstract class AggregateRoot
{
    private int $version = 0;

    /**
     * @var list<DomainEvent>
     */
    private array $recordedEvents = [];

    /**
     * @param list<DomainEvent> $events
     */
    public static function reconstituteFromHistory(array $events): static
    {
        $aggregate = new static();

        foreach ($events as $event) {
            $aggregate->apply($event);
            $aggregate->version++;
        }

        return $aggregate;
    }

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

    /**
     * @return list<DomainEvent>
     */
    public function recordedEvents(): array
    {
        return $this->recordedEvents;
    }

    public function markEventsCommitted(): void
    {
        $this->version += count($this->recordedEvents);
        $this->recordedEvents = [];
    }

    protected function recordThat(DomainEvent $event): void
    {
        $this->recordedEvents[] = $event;
        $this->apply($event);
    }

    private function apply(DomainEvent $event): void
    {
        $shortName = (new \ReflectionClass($event))->getShortName();
        $method = 'apply'.$shortName;

        if (! method_exists($this, $method)) {
            throw new RuntimeException(sprintf('Missing event applier [%s].', $method));
        }

        $this->{$method}($event);
    }
}

The persisted version only changes after events are committed. That lets the repository append new events with the version the aggregate was loaded from.

Implement the Order aggregate

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Order\Event\OrderConfirmed;
use App\Domain\Order\Event\OrderLineAdded;
use App\Domain\Order\Event\OrderPlaced;
use App\EventSourcing\AggregateRoot;
use DateTimeImmutable;
use DomainException;

final class Order extends AggregateRoot
{
    private string $id = '';

    private string $customerId = '';

    private string $status = 'new';

    /**
     * @var array<string, array{quantity: int, unit_price_cents: int}>
     */
    private array $lines = [];

    public static function place(string $orderId, string $customerId): self
    {
        $order = new self();

        $order->recordThat(new OrderPlaced(
            orderId: $orderId,
            customerId: $customerId,
            placedAt: new DateTimeImmutable(),
        ));

        return $order;
    }

    public function addLine(string $productId, int $quantity, int $unitPriceCents): void
    {
        if ($this->status !== 'draft') {
            throw new DomainException('Only draft orders can be changed.');
        }

        if ($quantity < 1) {
            throw new DomainException('Quantity must be at least 1.');
        }

        if ($unitPriceCents < 1) {
            throw new DomainException('Unit price must be positive.');
        }

        $this->recordThat(new OrderLineAdded(
            orderId: $this->id,
            productId: $productId,
            quantity: $quantity,
            unitPriceCents: $unitPriceCents,
            addedAt: new DateTimeImmutable(),
        ));
    }

    public function confirm(): void
    {
        if ($this->status !== 'draft') {
            throw new DomainException('Only draft orders can be confirmed.');
        }

        if ($this->lines === []) {
            throw new DomainException('Cannot confirm an empty order.');
        }

        $this->recordThat(new OrderConfirmed(
            orderId: $this->id,
            totalCents: $this->totalCents(),
            confirmedAt: new DateTimeImmutable(),
        ));
    }

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

    private function totalCents(): int
    {
        $total = 0;

        foreach ($this->lines as $line) {
            $total += $line['quantity'] * $line['unit_price_cents'];
        }

        return $total;
    }

    protected function applyOrderPlaced(OrderPlaced $event): void
    {
        $this->id = $event->orderId;
        $this->customerId = $event->customerId;
        $this->status = 'draft';
    }

    protected function applyOrderLineAdded(OrderLineAdded $event): void
    {
        $existing = $this->lines[$event->productId] ?? [
            'quantity' => 0,
            'unit_price_cents' => $event->unitPriceCents,
        ];

        $this->lines[$event->productId] = [
            'quantity' => $existing['quantity'] + $event->quantity,
            'unit_price_cents' => $event->unitPriceCents,
        ];
    }

    protected function applyOrderConfirmed(OrderConfirmed $event): void
    {
        $this->status = 'confirmed';
    }
}

Notice the rule: public methods validate intent and record events. apply* methods only mutate state from events. They do not validate business rules because replay must never fail because a current rule changed.

Add the repository

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use App\EventSourcing\PdoEventStore;
use RuntimeException;

final readonly class EventSourcedOrderRepository
{
    public function __construct(
        private PdoEventStore $events,
    ) {}

    public function get(string $orderId): Order
    {
        $history = $this->events->load($this->streamName($orderId));

        if ($history === []) {
            throw new RuntimeException(sprintf('Order [%s] was not found.', $orderId));
        }

        return Order::reconstituteFromHistory($history);
    }

    /**
     * @param array<string, mixed> $metadata
     */
    public function save(Order $order, array $metadata = []): void
    {
        $recordedEvents = $order->recordedEvents();

        if ($recordedEvents === []) {
            return;
        }

        $this->events->append(
            streamName: $this->streamName($order->id()),
            expectedVersion: $order->version(),
            events: $recordedEvents,
            metadata: $metadata,
        );

        $order->markEventsCommitted();
    }

    private function streamName(string $orderId): string
    {
        return 'order-'.$orderId;
    }
}

The repository owns stream naming. The aggregate should not care whether events are stored in PostgreSQL, EventStoreDB, files, or an in-memory test store.

Handle commands

Command:

<?php

declare(strict_types=1);

namespace App\Application\Order\Command;

final readonly class PlaceOrder
{
    /**
     * @param list<array{product_id: string, quantity: int, unit_price_cents: int}> $lines
     */
    public function __construct(
        public string $orderId,
        public string $customerId,
        public array $lines,
        public string $requestId,
        public string $userId,
    ) {}
}

Handler:

<?php

declare(strict_types=1);

namespace App\Application\Order\Command;

use App\Domain\Order\EventSourcedOrderRepository;
use App\Domain\Order\Order;

final readonly class PlaceOrderHandler
{
    public function __construct(
        private EventSourcedOrderRepository $orders,
    ) {}

    public function __invoke(PlaceOrder $command): void
    {
        $order = Order::place(
            orderId: $command->orderId,
            customerId: $command->customerId,
        );

        foreach ($command->lines as $line) {
            $order->addLine(
                productId: $line['product_id'],
                quantity: $line['quantity'],
                unitPriceCents: $line['unit_price_cents'],
            );
        }

        $order->confirm();

        $this->orders->save($order, [
            'correlation_id' => $command->requestId,
            'causation_id' => $command->requestId,
            'user_id' => $command->userId,
        ]);
    }
}

The command handler does not insert rows directly. It asks the aggregate to make decisions, then saves the events.

Build a read model

Do not query the event store for screens. Build projections.

create table order_summaries (
    order_id varchar(36) primary key,
    customer_id varchar(36) not null,
    status varchar(50) not null,
    line_count integer not null default 0,
    total_cents integer not null default 0,
    placed_at timestamptz not null,
    confirmed_at timestamptz null,
    updated_at timestamptz not null
);

create table projection_checkpoints (
    projection_name varchar(255) primary key,
    last_event_id bigint not null
);

create table projection_events (
    projection_name varchar(255) not null,
    event_id bigint not null,
    primary key (projection_name, event_id)
);

projection_events prevents duplicate application of the same event. That matters because projector runners are often retried.

Projector:

<?php

declare(strict_types=1);

namespace App\Projection;

use App\Domain\Order\Event\OrderConfirmed;
use App\Domain\Order\Event\OrderLineAdded;
use App\Domain\Order\Event\OrderPlaced;
use App\EventSourcing\StoredEvent;
use PDO;

final readonly class OrderSummaryProjector
{
    public function name(): string
    {
        return 'order_summary';
    }

    public function project(StoredEvent $storedEvent, PDO $pdo): void
    {
        $event = $storedEvent->event;

        match (true) {
            $event instanceof OrderPlaced => $this->onOrderPlaced($event, $pdo),
            $event instanceof OrderLineAdded => $this->onOrderLineAdded($event, $pdo),
            $event instanceof OrderConfirmed => $this->onOrderConfirmed($event, $pdo),
            default => null,
        };
    }

    private function onOrderPlaced(OrderPlaced $event, PDO $pdo): void
    {
        $statement = $pdo->prepare(
            'insert into order_summaries
                (order_id, customer_id, status, placed_at, updated_at)
             values
                (:order_id, :customer_id, :status, :placed_at, :updated_at)
             on conflict (order_id) do nothing'
        );

        $statement->execute([
            'order_id' => $event->orderId,
            'customer_id' => $event->customerId,
            'status' => 'draft',
            'placed_at' => $event->placedAt->format(DATE_ATOM),
            'updated_at' => $event->placedAt->format(DATE_ATOM),
        ]);
    }

    private function onOrderLineAdded(OrderLineAdded $event, PDO $pdo): void
    {
        $statement = $pdo->prepare(
            'update order_summaries
             set line_count = line_count + :line_count,
                 total_cents = total_cents + :line_total,
                 updated_at = :updated_at
             where order_id = :order_id'
        );

        $statement->execute([
            'order_id' => $event->orderId,
            'line_count' => $event->quantity,
            'line_total' => $event->quantity * $event->unitPriceCents,
            'updated_at' => $event->addedAt->format(DATE_ATOM),
        ]);
    }

    private function onOrderConfirmed(OrderConfirmed $event, PDO $pdo): void
    {
        $statement = $pdo->prepare(
            'update order_summaries
             set status = :status,
                 total_cents = :total_cents,
                 confirmed_at = :confirmed_at,
                 updated_at = :updated_at
             where order_id = :order_id'
        );

        $statement->execute([
            'order_id' => $event->orderId,
            'status' => 'confirmed',
            'total_cents' => $event->totalCents,
            'confirmed_at' => $event->confirmedAt->format(DATE_ATOM),
            'updated_at' => $event->confirmedAt->format(DATE_ATOM),
        ]);
    }
}

This projection is designed for one screen. Another screen can have another projection. That is the point.

Run projectors safely

The projector runner reads events after its last checkpoint and applies them in order:

<?php

declare(strict_types=1);

namespace App\Projection;

use App\EventSourcing\PdoEventStore;
use App\EventSourcing\StoredEvent;
use PDO;
use Throwable;

final readonly class ProjectionRunner
{
    public function __construct(
        private PDO $pdo,
        private PdoEventStore $events,
        private OrderSummaryProjector $projector,
    ) {}

    public function runOnce(int $limit = 100): void
    {
        $lastEventId = $this->checkpoint();
        $events = $this->events->readAllAfter($lastEventId, $limit);

        foreach ($events as $event) {
            $this->projectOne($event);
        }
    }

    private function projectOne(StoredEvent $event): void
    {
        $this->pdo->beginTransaction();

        try {
            if (! $this->markEvent($event)) {
                $this->pdo->commit();

                return;
            }

            $this->projector->project($event, $this->pdo);
            $this->storeCheckpoint($event->id);

            $this->pdo->commit();
        } catch (Throwable $exception) {
            $this->pdo->rollBack();

            throw $exception;
        }
    }

    private function checkpoint(): int
    {
        $statement = $this->pdo->prepare(
            'select last_event_id
             from projection_checkpoints
             where projection_name = :projection_name'
        );

        $statement->execute(['projection_name' => $this->projector->name()]);

        return (int) ($statement->fetchColumn() ?: 0);
    }

    private function markEvent(StoredEvent $event): bool
    {
        $statement = $this->pdo->prepare(
            'insert into projection_events (projection_name, event_id)
             values (:projection_name, :event_id)
             on conflict do nothing'
        );

        $statement->execute([
            'projection_name' => $this->projector->name(),
            'event_id' => $event->id,
        ]);

        return $statement->rowCount() === 1;
    }

    private function storeCheckpoint(int $eventId): void
    {
        $statement = $this->pdo->prepare(
            'insert into projection_checkpoints (projection_name, last_event_id)
             values (:projection_name, :last_event_id)
             on conflict (projection_name) do update set
                last_event_id = excluded.last_event_id'
        );

        $statement->execute([
            'projection_name' => $this->projector->name(),
            'last_event_id' => $eventId,
        ]);
    }
}

The projection update, duplicate marker, and checkpoint are committed together. If the process crashes before commit, nothing is half-applied. If the same event is retried after commit, projection_events skips it.

Run it from a worker:

while (true) {
    $runner->runOnce(limit: 500);
    usleep(250_000);
}

In a real application, add signal handling, logging, metrics, and backoff when no events are available.

Run only one worker instance per projection, or use a database advisory lock around the projection name. If two workers advance the same checkpoint independently, one failed event can be skipped by a later checkpoint.

[IMAGE: Supporting visual 2 for Building Event Sourcing Systems in PHP: A Step-by-Step Implementation, showing PHP Architecture decisions, examples, and PHP, Event Sourcing, Architecture. Alt: PHP Architecture building-event-sourcing-systems-php-step-by-step-implementation visual 2]

Query the read model

<?php

declare(strict_types=1);

namespace App\Application\Order\Query;

use PDO;
use RuntimeException;

final readonly class GetOrderSummaryHandler
{
    public function __construct(
        private PDO $pdo,
    ) {}

    /**
     * @return array<string, mixed>
     */
    public function __invoke(string $orderId): array
    {
        $statement = $this->pdo->prepare(
            'select order_id, customer_id, status, line_count, total_cents, placed_at, confirmed_at
             from order_summaries
             where order_id = :order_id'
        );

        $statement->execute(['order_id' => $orderId]);

        $row = $statement->fetch(PDO::FETCH_ASSOC);

        if (! is_array($row)) {
            throw new RuntimeException(sprintf('Order summary [%s] was not found.', $orderId));
        }

        return $row;
    }
}

Reads are now simple. The price is that projections are another moving part.

Add a process manager

[IMAGE: Supporting visual 2 for Building Event Sourcing Systems in PHP: A Step-by-Step Implementation, showing PHP Architecture decisions, examples, and PHP, Event Sourcing, Architecture. Alt: PHP Architecture building-event-sourcing-systems-php-step-by-step-implementation visual 2]

A process manager reacts to events and issues commands. It coordinates a workflow but does not own aggregate state.

Example: once an order is confirmed, reserve inventory and capture payment.

<?php

declare(strict_types=1);

namespace App\ProcessManager;

use App\Application\Inventory\Command\ReserveInventory;
use App\Application\Payment\Command\CapturePayment;
use App\Domain\Order\Event\OrderConfirmed;
use App\EventSourcing\StoredEvent;

final readonly class OrderFulfillmentProcessManager
{
    public function __construct(
        private CommandBus $commands,
    ) {}

    public function handle(StoredEvent $storedEvent): void
    {
        $event = $storedEvent->event;

        if (! $event instanceof OrderConfirmed) {
            return;
        }

        $this->commands->dispatch(new ReserveInventory(
            orderId: $event->orderId,
            idempotencyKey: $storedEvent->eventId.':reserve-inventory',
        ));

        $this->commands->dispatch(new CapturePayment(
            orderId: $event->orderId,
            amountCents: $event->totalCents,
            idempotencyKey: $storedEvent->eventId.':capture-payment',
        ));
    }
}

The idempotency keys matter. Process managers often run at least once, not exactly once. Every command they send should be safe to retry.

Do not let process managers call external APIs directly if you need reliable recovery. Emit commands or outbox messages, then let command handlers or workers perform the side effect with idempotency.

Test aggregates with given-when-then

Aggregate tests do not need a database:

<?php

declare(strict_types=1);

use App\Domain\Order\Event\OrderLineAdded;
use App\Domain\Order\Event\OrderPlaced;
use App\Domain\Order\Order;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;

final class OrderTest extends TestCase
{
    public function test_draft_order_can_be_confirmed(): void
    {
        $order = Order::reconstituteFromHistory([
            new OrderPlaced('order-1', 'customer-1', new DateTimeImmutable('2024-07-09T10:00:00Z')),
            new OrderLineAdded('order-1', 'product-1', 2, 1500, new DateTimeImmutable('2024-07-09T10:01:00Z')),
        ]);

        $order->confirm();

        $events = $order->recordedEvents();

        self::assertCount(1, $events);
        self::assertSame('order.confirmed', $events[0]->type());
    }
}

Given past events, when a command is executed, then assert the new events.

That test style keeps business rules independent from persistence.

Test the event store

Integration tests should prove append, load, and concurrency:

public function test_append_rejects_stale_expected_version(): void
{
    $store = $this->eventStore();

    $store->append('order-1', 0, [
        new OrderPlaced('order-1', 'customer-1', new DateTimeImmutable()),
    ]);

    $this->expectException(ConcurrencyException::class);

    $store->append('order-1', 0, [
        new OrderConfirmed('order-1', 1500, new DateTimeImmutable()),
    ]);
}

Also test that two events appended together get stream versions 1 and 2, then load in the same order.

Test projections for replay

Projectors must work when replayed from event zero:

public function test_order_summary_projection_can_be_replayed(): void
{
    $store = $this->eventStore();

    $store->append('order-1', 0, [
        new OrderPlaced('order-1', 'customer-1', new DateTimeImmutable('2024-07-09T10:00:00Z')),
        new OrderLineAdded('order-1', 'product-1', 2, 1500, new DateTimeImmutable('2024-07-09T10:01:00Z')),
        new OrderConfirmed('order-1', 3000, new DateTimeImmutable('2024-07-09T10:02:00Z')),
    ]);

    $runner = $this->projectionRunner();
    $runner->runOnce();
    $runner->runOnce();

    $summary = $this->orderSummary('order-1');

    self::assertSame('confirmed', $summary['status']);
    self::assertSame(3000, (int) $summary['total_cents']);
}

Running the projector twice should not duplicate lines or totals.

Snapshots are an optimization

Long streams can become expensive to rehydrate. Add snapshots only after measuring.

create table aggregate_snapshots (
    stream_name varchar(255) primary key,
    stream_version integer not null,
    payload_json text not null,
    recorded_at timestamptz not null default now()
);

Snapshot rules:

  • The event stream remains the source of truth.
  • A snapshot can be deleted and rebuilt.
  • A snapshot schema needs versioning too.
  • Rehydrate from the latest snapshot, then replay later events.
  • Do not use snapshots to hide bad aggregate boundaries.

If one aggregate stream has millions of events, first ask whether the aggregate is too large.

Version events deliberately

Events live longer than code. Plan for change:

order.placed v1:
  order_id
  customer_id
  placed_at

order.placed v2:
  order_id
  customer_id
  currency
  placed_at

Three rules help:

  • Add optional fields before removing fields.
  • Keep event type strings stable.
  • Convert old payloads during deserialization with upcasters.

Simple upcaster:

/**
 * @param array<string, mixed> $payload
 * @return array<string, mixed>
 */
function upcastOrderPlaced(int $version, array $payload): array
{
    if ($version === 1) {
        $payload['currency'] = 'USD';
    }

    return $payload;
}

Do not edit old event rows in place unless you have a controlled migration strategy and backups. The safer default is immutable events plus upcasting.

Privacy and compliance

[IMAGE: Supporting visual 3 for Building Event Sourcing Systems in PHP: A Step-by-Step Implementation, showing PHP Architecture decisions, examples, and PHP, Event Sourcing, Architecture. Alt: PHP Architecture building-event-sourcing-systems-php-step-by-step-implementation visual 3]

Event sourcing and personal data need careful design.

Bad event:

{
  "type": "customer.email_changed",
  "old_email": "old@example.com",
  "new_email": "new@example.com"
}

Better event:

{
  "type": "customer.email_changed",
  "customer_id": "customer-123"
}

Store personal data in a separate mutable store when possible, then reference it by ID from events. If personal data must be in events, consider encryption keys per subject so deleting the key makes the data unreadable while the event stream remains structurally intact.

[IMAGE: Supporting visual 3 for Building Event Sourcing Systems in PHP: A Step-by-Step Implementation, showing PHP Architecture decisions, examples, and PHP, Event Sourcing, Architecture. Alt: PHP Architecture building-event-sourcing-systems-php-step-by-step-implementation visual 3]

Do this design before launch. Retrofitting privacy into immutable history is painful.

When not to use event sourcing

Do not use it when:

  • the domain is simple CRUD;
  • the team only needs an audit log;
  • business users do not care about historical reconstruction;
  • read models would duplicate the whole database with no benefit;
  • the team cannot operate projection workers and replay tooling;
  • deleting or changing historical personal data is a core requirement.

An audit table is often enough:

create table audit_log (
    id bigserial primary key,
    actor_id varchar(36) not null,
    action varchar(255) not null,
    subject_type varchar(255) not null,
    subject_id varchar(36) not null,
    payload_json text not null,
    recorded_at timestamptz not null default now()
);

Event sourcing is a persistence model, not a logging feature.

Production checklist

Before running this in production:

  • Event store writes are transactional.
  • (stream_name, stream_version) is unique.
  • Event IDs are unique and stable.
  • Metadata includes correlation and causation IDs.
  • Aggregates are tested with given-when-then tests.
  • Projectors track checkpoints.
  • Projection runners deduplicate processed events.
  • Process managers emit idempotent commands.
  • Replays are rehearsed in staging.
  • Event schema versioning is documented.
  • Personal data strategy is documented.
  • Queue lag and projection lag are monitored.
  • Backups include event store and read models, or read models can be rebuilt.

The event store is the system of record. Treat it with the same care you would give a payment ledger.

FAQ

What is PHP Architecture?

PHP Architecture is a practical architecture topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.

When should a team use PHP Architecture?

Use PHP Architecture when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.

What is the biggest risk with PHP Architecture?

The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.

How do you test PHP Architecture?

Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.

How does PHP Architecture affect SEO and AI search visibility?

It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.

Conclusion

PHP Architecture is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.

Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.

Top