A practical guide to implementing event sourcing in PHP applications, covering event stores, projections, and real-world trade-offs.
Event sourcing stores state as a sequence of events rather than as a snapshot of current values. Instead of a balance column that reads 1,500.00, you store every transaction that led to that balance: opened account (0.00), deposited (1,000.00), deposited (750.00), withdrew (250.00).
The current state is derived by replaying events. The event log is the source of truth, and any "current state" view is a projection that can be rebuilt from scratch.
This is not a new concept. Your bank has always worked this way. Double-entry accounting is event sourcing. Git is event sourcing. The pattern is proven, but implementing it in a web application requires careful thought.
Strong fit:
Poor fit:
Do not adopt event sourcing because it is interesting. Adopt it because your domain genuinely benefits from it.
Events represent facts that happened. They are immutable, past tense, and contain all the data needed to describe what occurred.
class InvoiceFinalized implements ShouldBeStored
{
public function __construct(
public readonly string $invoiceId,
public readonly string $invoiceNumber,
public readonly int $totalInCents,
public readonly string $currency,
public readonly string $finalizedBy,
public readonly Carbon $finalizedAt,
) {}
}
Naming matters. Events are past tense: InvoiceFinalized, PaymentReceived, LineItemAdded. Not FinalizeInvoice (that is a command) or InvoiceUpdate (that says nothing).
Events are immutable. Once stored, an event never changes. If you discover a bug in event data, you publish a correcting event (like InvoiceCorrected), not modify the original.
The event store is an append-only log. Each entry contains:
stream_id | version | event_type | payload | recorded_at
inv-001 | 1 | InvoiceCreated | { "customer": ... } | 2026-01-15 09:00:00
inv-001 | 2 | LineItemAdded | { "product": ... } | 2026-01-15 09:01:00
inv-001 | 3 | InvoiceFinalized | { "total": 15000 } | 2026-01-15 09:05:00
In PHP, the spatie/laravel-event-sourcing package provides a solid event store implementation backed by your existing database. For higher-scale systems, EventStoreDB is a purpose-built event store.
Aggregates are the domain objects that decide whether an action is allowed and which events to record:
class InvoiceAggregate extends AggregateRoot
{
private InvoiceState $state;
public function createInvoice(string $customerId): self
{
$this->recordThat(new InvoiceCreated(
invoiceId: $this->uuid(),
customerId: $customerId,
createdAt: now(),
));
return $this;
}
public function finalize(string $userId): self
{
if ($this->state->isFinalized()) {
throw CannotFinalizeInvoice::alreadyFinalized($this->uuid());
}
if ($this->state->hasNoLines()) {
throw CannotFinalizeInvoice::noLineItems($this->uuid());
}
$this->recordThat(new InvoiceFinalized(
invoiceId: $this->uuid(),
invoiceNumber: $this->state->generateNumber(),
totalInCents: $this->state->calculateTotal(),
currency: $this->state->currency,
finalizedBy: $userId,
finalizedAt: now(),
));
return $this;
}
protected function applyInvoiceCreated(InvoiceCreated $event): void
{
$this->state = new InvoiceState(
customerId: $event->customerId,
);
}
protected function applyInvoiceFinalized(InvoiceFinalized $event): void
{
$this->state->markFinalized($event->finalizedAt);
}
}
The aggregate enforces business rules (cannot finalize without line items) and records events. The apply methods rebuild state from events.
Projections are read models built from events. They transform the event stream into structures optimized for querying:
class InvoiceProjector extends Projector
{
public function onInvoiceCreated(InvoiceCreated $event): void
{
InvoiceReadModel::create([
'id' => $event->invoiceId,
'customer_id' => $event->customerId,
'status' => 'draft',
'created_at' => $event->createdAt,
]);
}
public function onInvoiceFinalized(InvoiceFinalized $event): void
{
InvoiceReadModel::where('id', $event->invoiceId)
->update([
'status' => 'finalized',
'invoice_number' => $event->invoiceNumber,
'total' => $event->totalInCents,
'finalized_at' => $event->finalizedAt,
]);
}
}
The power of projections: You can create multiple projections from the same events. One for the invoice list view. Another for financial reporting. A third for customer dashboards. Each optimized for its specific read pattern.
Rebuilding projections: Since events are the source of truth, you can delete a projection table and rebuild it by replaying all events. This means you can fix projection bugs retroactively, add new views without migrating data, and restructure read models freely.
Your events will change over time. New fields are added, old fields become irrelevant. Handle this with upcasters that transform old event versions into the current schema:
class InvoiceFinalizedUpcaster implements EventUpcaster
{
public function upcast(array $payload, int $version): array
{
if ($version < 2) {
// Version 1 did not include currency
$payload['currency'] = 'EUR';
}
return $payload;
}
}
Replaying thousands of events to rebuild an aggregate on every request is expensive. Use snapshots:
// Store aggregate state periodically
class InvoiceAggregate extends AggregateRoot
{
// Snapshot every 100 events
protected int $storedEventCountThreshold = 100;
}
The aggregate loads the latest snapshot and replays only the events that occurred after it.
Event sourcing makes testing remarkably clean. Test inputs (commands) and outputs (events) without caring about implementation details:
public function test_finalizing_invoice_records_correct_total(): void
{
InvoiceAggregate::fake()
->given([
new InvoiceCreated(invoiceId: 'inv-1', customerId: 'cust-1', createdAt: now()),
new LineItemAdded(invoiceId: 'inv-1', productId: 'prod-1', quantity: 2, unitPrice: 5000),
])
->when(fn (InvoiceAggregate $agg) => $agg->finalize('user-1'))
->assertRecorded([
new InvoiceFinalized(
invoiceId: 'inv-1',
totalInCents: 10000,
// ... other fields
),
]);
}
Do not event-source your entire application. Pick one bounded context where the audit trail and historical replay capabilities genuinely solve a problem. Implement it, learn from the experience, and expand only if the pattern proves its value.
Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.
Book a 30-min Call