Event Sourcing & CQRS in Laravel: A Practical Introduction

· 10 min read

Traditional CRUD overwrites data. When you update a user's email, the old email is gone forever. Event Sourcing flips this: instead of storing current state, you store every change that happened. The current state is derived by replaying all events.

CRUD vs Event Sourcing

CRUD Approach

UPDATE users SET email = 'new@example.com' WHERE id = 1;
-- Old email? Gone forever. Why did it change? No idea.

Event Sourcing Approach

Event 1: UserRegistered { email: 'old@example.com', name: 'John' }
Event 2: EmailChanged   { old: 'old@example.com', new: 'new@example.com', reason: 'typo' }
Event 3: NameChanged     { old: 'John', new: 'John Doe' }

Current state = replay all events → { email: 'new@example.com', name: 'John Doe' }

Benefits:

  • Complete audit trail — every change, who made it, when, and why
  • Time travel — reconstruct state at any point in history
  • Debug-friendly — replay events to reproduce bugs
  • Analytics — mine historical events for insights

Trade-offs:

  • More complex than CRUD
  • Eventually consistent (read models may lag)
  • Storage grows over time
  • Steeper learning curve

When to Use Event Sourcing

Good Fit Bad Fit
Financial transactions Simple CRUD (blog posts)
Order/fulfillment systems User preferences
Audit-heavy domains (healthcare, legal) Content management
Complex business workflows Prototype/MVP
Systems needing "undo" High-write, low-read data

Setting Up with Spatie Event Sourcing

composer require spatie/laravel-event-sourcing

php artisan vendor:publish --provider="Spatie\EventSourcing\EventSourcingServiceProvider" --tag="event-sourcing-migrations"
php artisan migrate

This creates stored_events and snapshots tables.

Core Concepts

1. Events — What Happened

// app/Domain/Account/Events/AccountCreated.php

namespace App\Domain\Account\Events;

use Spatie\EventSourcing\StoredEvents\ShouldBeStored;

class AccountCreated extends ShouldBeStored
{
    public function __construct(
        public readonly string $name,
        public readonly string $userId,
        public readonly string $currency = 'USD',
    ) {}
}
// app/Domain/Account/Events/MoneyDeposited.php

class MoneyDeposited extends ShouldBeStored
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $description,
    ) {}
}
// app/Domain/Account/Events/MoneyWithdrawn.php

class MoneyWithdrawn extends ShouldBeStored
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $description,
    ) {}
}

2. Aggregates — Business Rules

Aggregates enforce business rules before recording events:

// app/Domain/Account/AccountAggregate.php

namespace App\Domain\Account;

use App\Domain\Account\Events\AccountCreated;
use App\Domain\Account\Events\MoneyDeposited;
use App\Domain\Account\Events\MoneyWithdrawn;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

class AccountAggregate extends AggregateRoot
{
    private int $balanceInCents = 0;
    private int $overdraftLimit = 0;

    // Apply events to rebuild state
    public function applyAccountCreated(AccountCreated $event): void
    {
        // Initial state when account is created
    }

    public function applyMoneyDeposited(MoneyDeposited $event): void
    {
        $this->balanceInCents += $event->amountInCents;
    }

    public function applyMoneyWithdrawn(MoneyWithdrawn $event): void
    {
        $this->balanceInCents -= $event->amountInCents;
    }

    // Commands with business rules
    public function createAccount(string $name, string $userId): self
    {
        $this->recordThat(new AccountCreated(
            name: $name,
            userId: $userId,
        ));

        return $this;
    }

    public function deposit(int $amountInCents, string $description): self
    {
        if ($amountInCents <= 0) {
            throw new \InvalidArgumentException('Deposit amount must be positive');
        }

        $this->recordThat(new MoneyDeposited(
            amountInCents: $amountInCents,
            description: $description,
        ));

        return $this;
    }

    public function withdraw(int $amountInCents, string $description): self
    {
        if ($amountInCents <= 0) {
            throw new \InvalidArgumentException('Withdrawal amount must be positive');
        }

        $newBalance = $this->balanceInCents - $amountInCents;

        if ($newBalance < -$this->overdraftLimit) {
            throw new \DomainException(
                "Insufficient funds. Balance: {$this->balanceInCents}, Requested: {$amountInCents}"
            );
        }

        $this->recordThat(new MoneyWithdrawn(
            amountInCents: $amountInCents,
            description: $description,
        ));

        return $this;
    }
}

Key pattern: recordThat() doesn't save immediately. It records the event. When you call persist(), all events are stored atomically.

3. Projectors — Read Models

Projectors listen to events and build queryable read models:

// app/Domain/Account/Projectors/AccountProjector.php

namespace App\Domain\Account\Projectors;

use App\Domain\Account\Events\AccountCreated;
use App\Domain\Account\Events\MoneyDeposited;
use App\Domain\Account\Events\MoneyWithdrawn;
use App\Models\Account;
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;

class AccountProjector extends Projector
{
    public function onAccountCreated(AccountCreated $event): void
    {
        Account::create([
            'uuid' => $event->aggregateRootUuid(),
            'name' => $event->name,
            'user_id' => $event->userId,
            'currency' => $event->currency,
            'balance_in_cents' => 0,
        ]);
    }

    public function onMoneyDeposited(MoneyDeposited $event): void
    {
        $account = Account::where('uuid', $event->aggregateRootUuid())->first();

        $account->update([
            'balance_in_cents' => $account->balance_in_cents + $event->amountInCents,
        ]);
    }

    public function onMoneyWithdrawn(MoneyWithdrawn $event): void
    {
        $account = Account::where('uuid', $event->aggregateRootUuid())->first();

        $account->update([
            'balance_in_cents' => $account->balance_in_cents - $event->amountInCents,
        ]);
    }
}

Register projectors:

// config/event-sourcing.php
'projectors' => [
    App\Domain\Account\Projectors\AccountProjector::class,
],

4. Reactors — Side Effects

Reactors handle side effects (emails, notifications) that shouldn't be replayed:

// app/Domain/Account/Reactors/SendLowBalanceAlert.php

namespace App\Domain\Account\Reactors;

use App\Domain\Account\Events\MoneyWithdrawn;
use App\Models\Account;
use App\Notifications\LowBalanceNotification;
use Spatie\EventSourcing\EventHandlers\Reactors\Reactor;

class SendLowBalanceAlert extends Reactor
{
    public function onMoneyWithdrawn(MoneyWithdrawn $event): void
    {
        $account = Account::where('uuid', $event->aggregateRootUuid())->first();

        if ($account->balance_in_cents < 1000) { // Below $10
            $account->user->notify(new LowBalanceNotification($account));
        }
    }
}

Projectors vs Reactors:

  • Projectors: rebuild read models (replayed during event:replay)
  • Reactors: side effects (NOT replayed — you don't want to resend 1000 emails)

Using It in a Controller

// app/Http/Controllers/AccountController.php

use App\Domain\Account\AccountAggregate;
use Illuminate\Support\Str;

class AccountController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|string|max:255',
        ]);

        $uuid = Str::uuid()->toString();

        AccountAggregate::retrieve($uuid)
            ->createAccount(
                name: $request->name,
                userId: $request->user()->id,
            )
            ->persist();

        return redirect()->route('accounts.show', $uuid);
    }

    public function deposit(Request $request, string $uuid)
    {
        $request->validate([
            'amount' => 'required|numeric|min:0.01',
            'description' => 'required|string|max:255',
        ]);

        AccountAggregate::retrieve($uuid)
            ->deposit(
                amountInCents: (int) ($request->amount * 100),
                description: $request->description,
            )
            ->persist();

        return back()->with('success', 'Deposit recorded');
    }

    public function withdraw(Request $request, string $uuid)
    {
        $request->validate([
            'amount' => 'required|numeric|min:0.01',
            'description' => 'required|string|max:255',
        ]);

        try {
            AccountAggregate::retrieve($uuid)
                ->withdraw(
                    amountInCents: (int) ($request->amount * 100),
                    description: $request->description,
                )
                ->persist();
        } catch (\DomainException $e) {
            return back()->withErrors(['amount' => $e->getMessage()]);
        }

        return back()->with('success', 'Withdrawal recorded');
    }
}

CQRS: Separating Read and Write

CQRS (Command Query Responsibility Segregation) naturally emerges from Event Sourcing:

  • Write side: Aggregates (enforce rules, record events)
  • Read side: Projectors build optimized read models

You can have multiple projectors for the same events:

// Projector 1: Account balance (for the UI)
class AccountProjector extends Projector { ... }

// Projector 2: Transaction history (for reports)
class TransactionHistoryProjector extends Projector
{
    public function onMoneyDeposited(MoneyDeposited $event): void
    {
        TransactionLog::create([
            'account_uuid' => $event->aggregateRootUuid(),
            'type' => 'deposit',
            'amount_in_cents' => $event->amountInCents,
            'description' => $event->description,
            'occurred_at' => $event->createdAt(),
        ]);
    }

    public function onMoneyWithdrawn(MoneyWithdrawn $event): void
    {
        TransactionLog::create([
            'account_uuid' => $event->aggregateRootUuid(),
            'type' => 'withdrawal',
            'amount_in_cents' => $event->amountInCents,
            'description' => $event->description,
            'occurred_at' => $event->createdAt(),
        ]);
    }
}

// Projector 3: Daily summary (for analytics)
class DailySummaryProjector extends Projector { ... }

Replaying Events

When you change a projector or add a new one, rebuild the read model:

# Replay all events through a specific projector
php artisan event-sourcing:replay App\\Domain\\Account\\Projectors\\AccountProjector

# Replay all projectors
php artisan event-sourcing:replay

This is Event Sourcing's superpower: You add a new projector for dashboard analytics. Replay all events from the beginning → the dashboard has full historical data without complex migrations or backfills.

Important: Projectors can be replayed, Reactors CANNOT. Because Reactors send emails, notifications — you don't want to re-send thousands of emails during a replay.

Snapshots (Performance)

For aggregates with thousands of events, replaying from event #1 is slow. Snapshots save aggregate state periodically:

class AccountAggregate extends AggregateRoot
{
    // Store snapshot every 100 events
    protected int $aggregateVersionAfterRestore = 0;

    public function shouldSnapshot(): bool
    {
        return $this->aggregateVersion % 100 === 0;
    }
}
# Create snapshot manually
php artisan event-sourcing:create-snapshot App\\Domain\\Account\\AccountAggregate --uuid=account-uuid

When to use snapshots: When an aggregate has many events (>100). For accounts with few transactions, don't bother — the overhead isn't worth it.

Event Versioning

Events are immutable — once stored, they never change. But business needs evolve and schemas change. Solution: versioning with backward-compatible defaults.

// Version 1: Original
class MoneyDeposited extends ShouldBeStored
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $description,
    ) {}
}

// Version 2: Added currency field (v2 events will have currency, v1 won't)
class MoneyDeposited extends ShouldBeStored
{
    public function __construct(
        public readonly int $amountInCents,
        public readonly string $description,
        public readonly string $currency = 'USD', // Default for old events
    ) {}
}

Tip: Always use default values for new fields. Old events in the database don't have the new field → PHP uses the default during deserialization.

For larger changes (renaming fields, removing fields), use event upcasters:

// config/event-sourcing.php
'event_class_map' => [
    'money_deposited_v1' => MoneyDeposited::class,
],
app/Domain/
├── Account/
│   ├── Events/
│   │   ├── AccountCreated.php
│   │   ├── MoneyDeposited.php
│   │   └── MoneyWithdrawn.php
│   ├── Projectors/
│   │   ├── AccountProjector.php
│   │   └── TransactionHistoryProjector.php
│   ├── Reactors/
│   │   └── SendLowBalanceAlert.php
│   ├── AccountAggregate.php
│   └── Exceptions/
│       └── InsufficientFundsException.php
├── Order/
│   ├── Events/
│   ├── Projectors/
│   └── OrderAggregate.php

Organize by domain (Account, Order), not by type (Events, Projectors). Each domain contains all its own components.

Testing

Spatie provides an elegant testing API for aggregates using given/when/then:

// Test business rules
public function test_cannot_withdraw_more_than_balance(): void
{
    AccountAggregate::fake()
        ->given([
            new AccountCreated(name: 'Test', userId: '1'),
            new MoneyDeposited(amountInCents: 5000, description: 'Initial'),
        ])
        ->when(function (AccountAggregate $aggregate) {
            $aggregate->withdraw(10000, 'Too much');
        })
        ->assertNotRecorded(MoneyWithdrawn::class);
}

// Test correct event is recorded
public function test_deposit_records_event(): void
{
    AccountAggregate::fake()
        ->given([
            new AccountCreated(name: 'Test', userId: '1'),
        ])
        ->when(function (AccountAggregate $aggregate) {
            $aggregate->deposit(5000, 'Salary');
        })
        ->assertRecorded([
            new MoneyDeposited(amountInCents: 5000, description: 'Salary'),
        ]);
}

// Test projector
public function test_projector_creates_account(): void
{
    event(new AccountCreated(
        name: 'Test Account',
        userId: '1',
        aggregateRootUuid: $uuid = Str::uuid()->toString(),
    ));

    $this->assertDatabaseHas('accounts', [
        'uuid' => $uuid,
        'name' => 'Test Account',
        'balance_in_cents' => 0,
    ]);
}

The given/when/then pattern:

  • given() — set up state by applying events. Doesn't run projectors.
  • when() — perform the action under test
  • assertRecorded() — verify which events were recorded
  • assertNotRecorded() — verify an event was NOT recorded

Gotchas & Lessons Learned

1. Don't Query Inside Aggregates

// ❌ Wrong — aggregates should not query the database
public function deposit(int $amount): self
{
    $user = User::find($this->userId); // DON'T!
    // ...
}

// ✅ Right — pass required data through parameters
public function deposit(int $amount, string $description): self
{
    // Only use internal state from events
}

Aggregates should be pure — only use state derived from events, never query external sources.

2. Events Should Be Small and Specific

// ❌ Too generic
class AccountUpdated extends ShouldBeStored {
    public function __construct(public array $changes) {}
}

// ✅ Specific — each event has one clear meaning
class MoneyDeposited extends ShouldBeStored { ... }
class MoneyWithdrawn extends ShouldBeStored { ... }
class AccountNameChanged extends ShouldBeStored { ... }

3. Eventual Consistency

Projectors run asynchronously (if configured with queues). The read model may lag by a few milliseconds. Your UI needs to handle this — show optimistic updates or use polling.

Conclusion

Event Sourcing is powerful but not free. It adds significant complexity — event versioning, eventual consistency, replay management. Only use it when you truly need:

  1. Complete audit trail — every change recorded permanently
  2. Replay/rebuild state — add new projections, debug by replaying
  3. Complex business rules — aggregates enforce invariants explicitly
  4. Temporal queries — "what was the state at time X?"

For most CRUD apps, traditional Eloquent is fine. Event Sourcing fits best for financial domains, e-commerce, and systems requiring compliance.

Start small: Event source one aggregate (Order, Account), keep the rest CRUD. No need for all-or-nothing.

Comments