Back to blog
event sourcingsoftware architectureknowledge base

Event Sourcing explained: when to use it and how it works

December 11, 2023
Event Sourcing explained: when to use it and how it works

Most applications store data as current state. When something changes, the previous value is overwritten. This is simple and works well for many use cases. But it discards history.

Event sourcing takes a different approach. Instead of storing the current state of your data, you store every change as an immutable event. The current state is derived by replaying those events in order. The sequence of events becomes the source of truth.

This guide explains the event sourcing pattern in depth: how it works, the core components, how to implement it in PHP and Laravel, and when the added complexity is worth it.

What is event sourcing?

Event sourcing is a data storage pattern where application state is stored as a sequence of events rather than as current state. Each event represents something that happened: OrderPlaced, OrderCanceled, PaymentReceived. Events are immutable once stored. To get the current state of an entity, you replay the events in order.

The contrast with traditional storage makes this concrete:

  • Traditional: Store current state. UPDATE orders SET status = 'canceled' WHERE id = ?
  • Event sourcing: Append an event. INSERT INTO events (aggregate_id, event_type, payload) VALUES (?, 'OrderCanceled', ?)

The traditional approach is simpler but loses history. Event sourcing keeps everything.

The key components of an event sourcing system

An event-sourced system has five core components.

1. Events

Events are immutable records of things that happened. They use past tense names and carry the data that describes the change:

final class OrderPlaced
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $customerId,
        public readonly array $items,
        public readonly string $shippingAddress,
        public readonly DateTimeImmutable $occurredAt,
    ) {}
}

final class OrderCanceled
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $reason,
        public readonly DateTimeImmutable $occurredAt,
    ) {}
}

Events describe facts. OrderCanceled does not say "set status to canceled." It says "this order was canceled, for this reason, at this time." The interpretation is handled by the aggregate.

2. Aggregates

An aggregate is the domain object responsible for processing commands and generating events. Its state is reconstructed by replaying its events:

final class Order
{
    private string $id;
    private string $status;
    private string $customerId;
    private array $items = [];
    private array $recordedEvents = [];
    private int $version = 0;

    public static function place(
        string $orderId,
        string $customerId,
        array $items,
        string $shippingAddress,
    ): self {
        $order = new self();
        $order->applyAndRecord(new OrderPlaced(
            orderId: $orderId,
            customerId: $customerId,
            items: $items,
            shippingAddress: $shippingAddress,
            occurredAt: new DateTimeImmutable(),
        ));

        return $order;
    }

    public function cancel(string $reason): void
    {
        if ($this->status !== 'placed') {
            throw new InvalidOperationException(
                "Cannot cancel an order with status {$this->status}"
            );
        }

        $this->applyAndRecord(new OrderCanceled(
            orderId: $this->id,
            reason: $reason,
            occurredAt: new DateTimeImmutable(),
        ));
    }

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

    private function applyOrderCanceled(OrderCanceled $event): void
    {
        $this->status = 'canceled';
    }

    private function applyAndRecord(object $event): void
    {
        $this->apply($event);
        $this->recordedEvents[] = $event;
        $this->version++;
    }

    private function apply(object $event): void
    {
        $method = 'apply' . class_basename($event);
        if (method_exists($this, $method)) {
            $this->$method($event);
        }
    }

    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];
        return $events;
    }

    public static function reconstitute(array $events): self
    {
        $order = new self();
        foreach ($events as $event) {
            $order->apply($event);
            $order->version++;
        }
        return $order;
    }
}

The aggregate never reads from the database directly. It processes commands, generates events, and its state is always derived from those events.

3. The event store

The event store is an append-only log. Events are written once and never modified or deleted:

interface EventStore
{
    public function append(string $aggregateId, array $events, int $expectedVersion): void;
    public function load(string $aggregateId): array;
}

final class DatabaseEventStore implements EventStore
{
    public function append(string $aggregateId, array $events, int $expectedVersion): void
    {
        DB::transaction(function () use ($aggregateId, $events, $expectedVersion) {
            $currentVersion = DB::table('domain_events')
                ->where('aggregate_id', $aggregateId)
                ->max('version') ?? 0;

            if ($currentVersion !== $expectedVersion) {
                throw new ConcurrencyException(
                    "Expected version {$expectedVersion}, got {$currentVersion}"
                );
            }

            $version = $expectedVersion;
            foreach ($events as $event) {
                $version++;
                DB::table('domain_events')->insert([
                    'aggregate_id' => $aggregateId,
                    'event_type' => get_class($event),
                    'payload' => json_encode($event),
                    'version' => $version,
                    'occurred_at' => now(),
                ]);
            }
        });
    }

    public function load(string $aggregateId): array
    {
        return DB::table('domain_events')
            ->where('aggregate_id', $aggregateId)
            ->orderBy('version')
            ->get()
            ->map(fn ($row) => $this->deserialize($row->event_type, $row->payload))
            ->all();
    }

    private function deserialize(string $eventType, string $payload): object
    {
        $data = json_decode($payload, true);
        return new $eventType(...$data);
    }
}

The version check provides optimistic locking: if two processes try to update the same aggregate simultaneously, one will fail with a concurrency exception, preventing lost updates.

4. The repository

The aggregate repository ties the event store and the aggregate together:

final class OrderRepository
{
    public function __construct(
        private readonly EventStore $eventStore,
        private readonly EventDispatcher $events,
    ) {}

    public function save(Order $order): void
    {
        $newEvents = $order->releaseEvents();
        $this->eventStore->append($order->id, $newEvents, $order->version - count($newEvents));

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

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

        if (empty($events)) {
            throw new OrderNotFoundException($orderId);
        }

        return Order::reconstitute($events);
    }
}

5. Projections

Projections are read models built from events. They listen to the event stream and maintain a denormalized view optimized for queries:

final class OrderListProjection
{
    public function handleOrderPlaced(OrderPlaced $event): void
    {
        DB::table('order_list')->insert([
            'id' => $event->orderId,
            'customer_id' => $event->customerId,
            'status' => 'placed',
            'item_count' => count($event->items),
            'placed_at' => $event->occurredAt,
        ]);
    }

    public function handleOrderCanceled(OrderCanceled $event): void
    {
        DB::table('order_list')
            ->where('id', $event->orderId)
            ->update(['status' => 'canceled']);
    }
}

Projections can be rebuilt at any time by replaying the event stream. If you add a new projection for a new reporting requirement, replay the events to populate it. No manual backfilling required.

Using EventSauce with Laravel

EventSauce is a PHP library purpose-built for event sourcing. It handles serialization, aggregate reconstruction, and the event store abstraction, so you can focus on your domain.

Install via Composer:

composer require eventsauce/eventsauce

An aggregate using EventSauce looks like this:

use EventSauce\EventSourcing\AggregateRoot;
use EventSauce\EventSourcing\AggregateRootBehaviour;

final class Order implements AggregateRoot
{
    use AggregateRootBehaviour;

    private string $status = '';

    public static function place(
        OrderId $id,
        string $customerId,
        array $items,
    ): self {
        $order = new self($id);
        $order->recordThat(new OrderWasPlaced($customerId, $items));
        return $order;
    }

    protected function applyOrderWasPlaced(OrderWasPlaced $event): void
    {
        $this->status = 'placed';
    }

    public function cancel(string $reason): void
    {
        if ($this->status !== 'placed') {
            throw new InvalidOperationException('Cannot cancel this order');
        }

        $this->recordThat(new OrderWasCanceled($reason));
    }

    protected function applyOrderWasCanceled(OrderWasCanceled $event): void
    {
        $this->status = 'canceled';
    }
}

EventSauce also provides snapshot support for aggregates with long event histories, a message dispatcher for routing events to consumers, and tooling for testing aggregates in isolation.

Event sourcing and CQRS

Event sourcing and CQRS are natural partners. The event stream on the write side feeds projections on the read side. The write model is purely event-driven; the read side is a set of optimized views built from those events.

In practice:

  1. A command arrives and is handled by the aggregate
  2. The aggregate generates events and records them in the event store
  3. Event listeners update read-model projections asynchronously
  4. Queries read from those projections

This separation means the write side never needs to think about query performance, and the read side never needs to worry about business rules. For a detailed look at implementing CQRS alongside event sourcing, see our CQRS guide.

Event replay: rebuilding state from scratch

One of the most powerful features of event sourcing is the ability to rebuild any state from the event log. Add a new projection and replay the entire event stream to populate it. Fix a corrupted projection by replaying after the bug fix. Reconstruct what your system looked like at any past point in time.

For aggregates with many events, replaying everything on every load becomes slow. Snapshots solve this:

final class SnapshotRepository
{
    public function findWithEvents(string $aggregateId): Order
    {
        $snapshot = DB::table('order_snapshots')
            ->where('aggregate_id', $aggregateId)
            ->first();

        if (!$snapshot) {
            $events = DB::table('domain_events')
                ->where('aggregate_id', $aggregateId)
                ->orderBy('version')
                ->get()
                ->map(fn ($row) => $this->deserialize($row))
                ->all();

            return Order::reconstitute($events);
        }

        // Load only events after the snapshot version
        $recentEvents = DB::table('domain_events')
            ->where('aggregate_id', $aggregateId)
            ->where('version', '>', $snapshot->version)
            ->orderBy('version')
            ->get()
            ->map(fn ($row) => $this->deserialize($row))
            ->all();

        return Order::restoreFromSnapshot(
            json_decode($snapshot->payload, true),
            $recentEvents,
        );
    }
}

Snapshots reduce replay time for long-lived aggregates without changing the fundamental append-only nature of the event store.

Architectural advantages

Event sourcing provides several structural advantages beyond its core mechanism.

Complete audit trail

Every change is recorded with context: what happened, when, and what data was involved. For regulatory compliance in financial services, healthcare, or legal tech, this is often a hard requirement. Event sourcing makes it structural rather than bolted on afterward.

Time-travel debugging

Because every state transition is recorded, you can reconstruct exactly what your system looked like at any point in time. This makes debugging production issues significantly easier than working backward from corrupted current state.

New projections from existing events

Need a new dashboard view? Add a projection and replay. The event history already contains everything you need. You do not need to run migrations or backfill data manually.

Decoupled consumers

New services and listeners can subscribe to your event stream without any changes to the producing service. This supports gradual system evolution and loose coupling between services.

Event sourcing in a fintech context

Event sourcing is particularly well-suited to financial systems. Consider a payment processing flow:

PaymentInitiated
PaymentAuthorized
FundsSettled

PaymentInitiated
PaymentFailed

PaymentInitiated
PaymentAuthorized
RefundRequested
RefundProcessed

Each state transition is an event. The current payment status is a projection. The audit log is the event store itself. Regulatory queries are answered by replaying the event stream for a given period.

This pattern appears frequently in fintech applications. At Sandorian, we build this kind of infrastructure for European fintech and SaaS companies where auditability, compliance, and traceability are first-class requirements. Our software architecture services cover event-sourced system design, implementation, and long-term maintenance.

Scaling event-sourced systems

As event stores grow, a few performance considerations matter.

Partitioning and sharding: Partition the event store by aggregate type or tenant. Each partition can be stored, queried, and backed up independently.

Projection performance: Projections are built from events, so they can be parallelized. A slow projection is rebuilt in the background without blocking writes.

Snapshot policies: Take snapshots at regular intervals (every 100 or 1000 events) for aggregates that accumulate many events. Load from the latest snapshot and replay only subsequent events.

Message brokers: Use Apache Kafka, RabbitMQ, or Laravel queues to fan out events to multiple projections and downstream consumers asynchronously.

When event sourcing makes sense

Event sourcing is not a default architectural choice. It is a deliberate decision for specific problems.

Use event sourcing when:

  • Audit trails and full history are a core requirement
  • You need to reconstruct state at any point in time
  • Your domain has complex, event-driven workflows
  • You are combining it with CQRS for independent read/write scaling
  • The domain experts think in terms of events (financial ledgers, order workflows, reservation systems)

Avoid event sourcing when:

  • Your application is primarily CRUD with no audit requirements
  • Your team has no experience with the pattern and the learning curve cost is not justified
  • The additional infrastructure is not warranted by your actual requirements
  • Eventual consistency between write and read models is not acceptable for critical operations

Most teams that adopt event sourcing do so in specific parts of their system, not across the entire application. Start with the subdomain where the advantages are most tangible.

Frequently asked questions

What is an event in the context of event sourcing?

An event is an immutable record of something that happened in the system. Events are named in the past tense (OrderPlaced, PaymentReceived), carry the data associated with the change, and are stored in the order they occurred. They cannot be modified or deleted after being stored.

What is the event sourcing pattern?

Event sourcing is a data storage pattern where application state is stored as a sequence of immutable events rather than as current state. The current state of any entity is derived by replaying its events from the beginning. The event log is the source of truth.

How does event sourcing differ from traditional data storage?

Traditional storage overwrites current state on each change. You see where things are now but not how they got there. Event sourcing stores every change as an event. You can see the full history and reconstruct state at any point in time, but reading data requires maintaining separate read models (projections) because the event store is not optimized for arbitrary queries.

Can event sourcing be implemented in Laravel and PHP?

Yes. EventSauce is the recommended PHP library for event sourcing. It provides an aggregate root base class, event serialization, and an event store interface. Laravel's queue system works well for processing events asynchronously and updating projections.

What is a projection in event sourcing?

A projection is a read model built by processing events. It listens to the event stream and maintains a denormalized view optimized for a specific query. Projections can be rebuilt at any time by replaying the event log, which means you can add new projections without backfilling data manually.

What is the event store?

The event store is an append-only database of events. Events are written once and never modified. It provides two operations: append new events for an aggregate, and load all events for an aggregate in order. The event store is the authoritative source of truth for the write side of an event-sourced system.

What are snapshots and when do I need them?

A snapshot is a point-in-time capture of an aggregate's state, stored alongside the event log. When loading an aggregate, the system loads the most recent snapshot and replays only events that occurred after it. Snapshots are needed when aggregates accumulate hundreds or thousands of events and full replay becomes too slow for acceptable latency.

How do event sourcing and CQRS work together?

Event sourcing and CQRS are independent patterns that complement each other. Event sourcing provides the write model: an immutable event log. CQRS provides the read model: projections built from those events, optimized for queries. Together they give you a write side that is auditable and domain-focused and a read side that is fast and flexible. See our CQRS guide for implementation details.

What are the key components of an event sourcing system?

An event-sourced system has five key components: events (immutable records of state changes), aggregates (domain objects that process commands and generate events), the event store (append-only log of all events), the repository (loads and saves aggregates via the event store), and projections (read models built by processing the event stream).

What infrastructure do I need for event sourcing?

Nothing exotic. We run event-sourced systems on European infrastructure providers like UpCloud, Ploi Cloud, Hetzner, and Scaleway. The same Laravel application, the same deployment pipelines. Event sourcing is a code pattern, not an infrastructure requirement.

Do I need a special database for event sourcing?

No. We prefer running event-sourced systems on MySQL. A well-indexed domain_events table handles millions of events without issues. The append-only write pattern is actually a great fit for MySQL's InnoDB engine. You do not need Kafka, EventStoreDB, or any specialized event store to get started. Start simple, optimize later if needed.

Let's talk

We build the systems that power your business. Let us know how we can help.

Book a 30-min Call