Event Sourcing & CQRS in Laravel: A Practical Introduction
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,
],
Recommended Directory Structure
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 testassertRecorded()— verify which events were recordedassertNotRecorded()— 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:
- Complete audit trail — every change recorded permanently
- Replay/rebuild state — add new projections, debug by replaying
- Complex business rules — aggregates enforce invariants explicitly
- 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.