SEO Metadata
SEO Title Options
- Building Event Sourcing Systems in PHP: A Step-by-Step
- PHP Architecture: Practical 2026 Guide
- Architecture Playbook: PHP Architecture
Meta Description Options
- Learn PHP Architecture with a practical Architecture framework, expert mistakes, implementation steps, examples, FAQ, and schema-ready guidance.
- 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
- What PHP Architecture means
- Why it matters now
- Implementation framework
- Practical comparison
- Expert workflow
- Common mistakes
- Media and link plan
- Original technical deep dive
- FAQ
- Structured data
- Conclusion
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.
- Define the user problem and the production risk.
- Identify the smallest reliable implementation boundary.
- Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
- Add tests for the behavior that would hurt if it regressed.
- Document the trade-off, not only the final code.
- Measure the result with logs, metrics, or user-facing outcomes.
- 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 area | Strong approach | Weak approach | Why it matters |
|---|---|---|---|
| Scope | Solve one clear problem | Mix unrelated concerns | Focus improves testing and search intent |
| Architecture | Put logic in explicit classes or documented boundaries | Hide behavior in templates or incidental callbacks | Future changes stay easier to review |
| Data flow | Pass prepared data into the view or endpoint | Query or compute in presentation code | Reduces regressions and performance surprises |
| Testing | Cover the risky behavior directly | Test only the happy path | Catches production failures earlier |
| Documentation | Explain trade-offs and limits | Repeat generic definitions | Builds E-E-A-T and reader trust |
| Operations | Track logs, metrics, and rollback steps | Ship without measurement | Makes 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]
Media and link plan
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.]
Trustworthy outbound links
- PHP manual - use this as the trust reference for language-level reference.
- Google Search quality guidance - use this as the trust reference for people-first content and E-E-A-T alignment.
Internal linking opportunities
- Internal guide: Implementing CQRS in PHP: Separating Commands - use this when readers need a related Architecture follow-up.
- Internal guide: SOLID Principles in PHP: Practical Examples - use this when readers need a related Architecture follow-up.
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:
| Field | Purpose |
|---|---|
id | Global event position for projections. |
event_id | Stable event UUID for deduplication. |
stream_name | Aggregate stream, such as order-123. |
stream_version | Version inside one aggregate stream. |
event_type | Stable public event name, not necessarily PHP class name. |
event_version | Schema version for that event type. |
payload_json | Business data. |
metadata_json | Correlation 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:
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:
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:
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']),
);
}
}
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:
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:
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:
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:
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:
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
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
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:
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:
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:
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:
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
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.
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:
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.