Software Teams

Event Sourcing Implementation: From Theory to Production

A practical guide to implementing event sourcing in PHP applications, covering event stores, projections, and real-world trade-offs.

What Event Sourcing Actually Is

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.

When Event Sourcing Makes Sense

Strong fit:

  • Financial systems where audit trails are essential
  • Systems where you need to answer "how did we get to this state?"
  • Domains with complex business rules that evolve over time
  • Applications that need to rebuild historical views with different logic

Poor fit:

  • Simple CRUD applications
  • Systems where current state is the only thing that matters
  • Early-stage products where the domain model is still changing weekly
  • Teams without experience in event-driven architectures

Do not adopt event sourcing because it is interesting. Adopt it because your domain genuinely benefits from it.

The Core Components

Events

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

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

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

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.

Practical Considerations

Event Versioning

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;
    }
}

Performance

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.

Testing Event-Sourced Systems

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
            ),
        ]);
}

Start Small

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.

Let's talk about your software teams needs

Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.

Book a 30-min Call