fundamentals without a framework dependency
Most applications store only the current state of an entity and overwrite it on every change, permanently losing any information about how that state came to be. Event sourcing turns this principle around: instead of the current state, it stores the complete, immutable sequence of every event that led to that state, from which the current state can be recomputed at any time.
Table of Contents
- 1. State as a sequence of events instead of a current snapshot
- 2. Building a minimal event store yourself
- 3. Modeling aggregates and domain events as immutable objects
- 4. Reconstructing aggregates through replaying every event
- 5. Optimistic concurrency control when appending new events
- 6. Snapshotting: performance with a long event history
- 7. Projections: building read models from events
- 8. Event versioning: when the schema has to change
- 9. When event sourcing pays off, and when it does not
- 10. Summary
- 11. FAQ
1. State as a sequence of events instead of a current snapshot
The classic CRUD approach stores the current balance of a bank account in a single column and overwrites that value on every deposit or withdrawal with an UPDATE. Once that UPDATE has run, there is no way to tell how the balance came to be, how many individual transactions contributed to it, or exactly when each change happened, unless that history is painstakingly tracked in a separate table.
Event sourcing solves exactly this problem by storing a sequence of individual events instead of the current balance: AccountOpened, MoneyDeposited, MoneyWithdrawn. The current balance then follows at any time from walking through all of these events in order and summing up their individual effects, instead of being maintained as a standalone value. The major benefit is an inherently complete audit trail, plus the ability to reconstruct the historical state at any point in the past exactly, which often makes the decisive difference when debugging a real production incident.
2. Building a minimal event store yourself
At its core, an event store is an append-only table with columns for aggregate id, version number, event type, payload serialized as JSON, and a timestamp. Events are only ever appended, never modified or deleted, which is the basic prerequisite for a reliable audit trail.
The central append() method accepts an expected version number and implicitly checks, via a unique database constraint on aggregate id and version, whether another process has already stored events for the same aggregate in the meantime. The load() method returns every stored event for a given aggregate id in its original order.
<?php
declare(strict_types=1);
namespace App\EventSourcing;
interface StoredEvent
{
public function eventType(): string;
public function payload(): array;
}
final class ConcurrencyException extends \RuntimeException
{
}
final class EventStore
{
public function __construct(private readonly \PDO $pdo)
{
}
/**
* @param StoredEvent[] $events
* @throws ConcurrencyException if $expectedVersion no longer matches the stored version
*/
public function append(string $aggregateId, int $expectedVersion, array $events): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO event_store (aggregate_id, version, event_type, payload, occurred_at)
VALUES (:aggregate_id, :version, :event_type, :payload, :occurred_at)'
);
$version = $expectedVersion;
foreach ($events as $event) {
$version++;
try {
$stmt->execute([
'aggregate_id' => $aggregateId,
'version' => $version,
'event_type' => $event->eventType(),
'payload' => json_encode($event->payload()),
'occurred_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
]);
} catch (\PDOException $e) {
// Relies on a UNIQUE constraint on (aggregate_id, version) to
// detect a concurrent writer that already used this version.
throw new ConcurrencyException(
"Aggregate '{$aggregateId}' was modified concurrently", previous: $e
);
}
}
}
/**
* @return array{version:int,event_type:string,payload:array}[]
*/
public function load(string $aggregateId): array
{
$stmt = $this->pdo->prepare(
'SELECT version, event_type, payload FROM event_store
WHERE aggregate_id = :aggregate_id ORDER BY version ASC'
);
$stmt->execute(['aggregate_id' => $aggregateId]);
return array_map(
static fn (array $row) => [
'version' => (int) $row['version'],
'event_type' => $row['event_type'],
'payload' => json_decode($row['payload'], true),
],
$stmt->fetchAll(\PDO::FETCH_ASSOC)
);
}
}
3. Modeling aggregates and domain events as immutable objects
A domain event is modeled as an immutable, usually readonly object, such as OrderPlaced or OrderShipped, carrying exactly the payload that was relevant at the moment it occurred. An aggregate holds no state set directly from the outside, reconstructing it exclusively through an apply() method per event type that updates internal state based on that event.
New state changes only ever originate from command methods on the aggregate, such as ship(), which first check the applicable business rules and only then produce a new event. That new event is immediately applied to the in-memory state via apply() and additionally appended to a list of not-yet-persisted events, which get flushed to the event store together at the end of the request.
4. Reconstructing aggregates through replaying every event
Loading an aggregate in an event-sourced system does not mean reading a row from a table, it means loading every stored event for the given aggregate id in its original order and applying them one by one to a fresh, empty aggregate until the current state is reached.
This approach fully decouples reconstruction from the original creation and mutation methods: an aggregate created through place() and later changed through ship() can be reconstructed exactly the same way through pure event replay, with no need to call those methods again or re-check their business rules.
<?php
declare(strict_types=1);
namespace App\Order;
final class Order
{
private string $status = 'pending';
private int $version = 0;
/** @var object[] */
private array $uncommittedEvents = [];
private function __construct(private readonly string $orderId)
{
}
public static function place(string $orderId, int $totalCents): self
{
$order = new self($orderId);
$order->recordThat(new OrderPlaced($orderId, $totalCents));
return $order;
}
/**
* Reconstitutes an aggregate purely by replaying its historical events,
* without touching the "place" factory or any business rules again.
*
* @param array{version:int,event_type:string,payload:array}[] $storedEvents
*/
public static function reconstituteFromEvents(string $orderId, array $storedEvents): self
{
$order = new self($orderId);
foreach ($storedEvents as $stored) {
$order->apply(self::denormalize($stored['event_type'], $stored['payload']));
$order->version = $stored['version'];
}
return $order;
}
public function ship(): void
{
if ($this->status !== 'pending') {
throw new \DomainException("Order '{$this->orderId}' cannot be shipped from status '{$this->status}'");
}
$this->recordThat(new OrderShipped($this->orderId));
}
private function recordThat(object $event): void
{
$this->apply($event);
$this->uncommittedEvents[] = $event;
}
private function apply(object $event): void
{
$this->status = match ($event::class) {
OrderPlaced::class => 'pending',
OrderShipped::class => 'shipped',
};
}
private static function denormalize(string $type, array $payload): object
{
return match ($type) {
'order_placed' => new OrderPlaced($payload['order_id'], $payload['total_cents']),
'order_shipped' => new OrderShipped($payload['order_id']),
};
}
public function pullUncommittedEvents(): array
{
$events = $this->uncommittedEvents;
$this->uncommittedEvents = [];
return $events;
}
}
5. Optimistic concurrency control when appending new events
When two concurrent requests load the same aggregate and both produce new events based on that same starting state, a conflict arises without additional protection: the second write would silently overwrite the first, or produce an inconsistent end state that was never actually intended.
The common fix is optimistic concurrency control: every new event carries the expected version number of the aggregate as it was at load time. The event store checks atomically when appending, usually via a unique database constraint on aggregate id and version, whether that expected version still matches what is actually stored. If it no longer matches, the store throws a ConcurrencyException, which the calling code has to handle, typically by reloading the aggregate and retrying the original operation.
6. Snapshotting: performance with a long event history
For an aggregate with tens of thousands of events, loading it grows noticeably slower on every access, because the entire history has to be replayed from the beginning every single time, just to recompute the same current state that was already known at the last load.
Snapshotting solves that problem: at regular intervals, for example every hundred events, the already computed state of an aggregate is serialized together with its version number and stored separately. On the next load, the most recent snapshot is loaded first, and only the events added since then are applied on top of it, instead of the entire history since the aggregate was created.
<?php
declare(strict_types=1);
namespace App\EventSourcing;
final class SnapshotStore
{
public function __construct(private readonly \PDO $pdo)
{
}
public function save(string $aggregateId, int $version, array $state): void
{
$this->pdo->prepare(
'REPLACE INTO aggregate_snapshots (aggregate_id, version, state)
VALUES (:aggregate_id, :version, :state)'
)->execute([
'aggregate_id' => $aggregateId,
'version' => $version,
'state' => json_encode($state),
]);
}
public function loadLatest(string $aggregateId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT version, state FROM aggregate_snapshots WHERE aggregate_id = :aggregate_id'
);
$stmt->execute(['aggregate_id' => $aggregateId]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row === false
? null
: ['version' => (int) $row['version'], 'state' => json_decode($row['state'], true)];
}
}
// Loading now becomes: load the latest snapshot, then only replay events
// with a version greater than the snapshot's version, instead of every
// single event since the aggregate was first created.
function loadOrder(EventStore $store, SnapshotStore $snapshots, string $orderId): array
{
$snapshot = $snapshots->loadLatest($orderId);
$fromVersion = $snapshot['version'] ?? 0;
return array_filter(
$store->load($orderId),
static fn (array $event) => $event['version'] > $fromVersion
);
}
7. Projections: building read models from events
Event sourcing alone does not answer questions like all open orders for a given customer efficiently, since that would require potentially reconstructing many aggregates in full every single time. That is why the same event stream is additionally used to build denormalized projections, usually asynchronously through an event listener that writes every new event into a table optimized for queries.
This separation between write and read model deliberately overlaps with the CQRS pattern, covered as its own separate topic. Event sourcing supplies the underlying data source from which any number of different projections for different read access patterns can be derived, without the write model itself needing to know anything about them.
8. Event versioning: when the schema has to change
A central problem in practice is that the structure of an event stored two years ago often no longer matches exactly what the current code expects, because business requirements have changed, for example through a newly added required field. Since already stored events must remain immutable, though, that historical schema cannot simply be adjusted after the fact.
The common fix is upcasting: a small transformation layer that automatically converts an old event format into the currently expected format on load, for example by filling a missing field with a sensible default value. For genuinely incompatible changes, a new, versioned event type is introduced instead, while the old type remains readable for historical data.
9. When event sourcing pays off, and when it does not
Event sourcing pays off especially for domains with a strong need for traceability, such as financial transactions, order processing, or contract changes, and for complex business logic where the history over time matters, not just the final state.
For simple, CRUD-heavy areas with no real business need for history, event sourcing is often just added overhead: a higher entry barrier for new team members, more expensive queries even for simple listings, and extra infrastructure for projections. The decision should therefore be made per bounded context, not as a blanket architectural choice for an entire application.
| Aspect | Classic CRUD persistence | Event sourcing |
|---|---|---|
| Stored information | Current state only | Complete sequence of every state change |
| Audit trail | Has to be maintained separately | Inherently present |
| Historical state at a point in time | Usually not reconstructable | Possible through replay up to that point |
| Write speed for simple changes | Direct UPDATE, very fast | Extra append plus possible projection update |
| Read speed for complex queries | Directly via a SQL query | Only practical through additional projections |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
Event Sourcing in PHP: The Essentials
Core idea
Store events instead of a snapshot, the current state follows from replaying all events.
Event store
An append-only store with a version number per aggregate forms the foundation.
Scaling
Snapshotting avoids replaying the entire history on every single load.
Where it fits
Worth it for domains with a real need for traceability, not as a blanket default.