Event sourcing stores state changes as a sequence of events rather than overwriting current state. This pattern enables powerful capabilities like audit trails, time travel, and event replay. But it introduces complexity that isn't always warranted.
Traditional vs Event Sourced
Traditional CRUD
In traditional applications, you update records in place. When a user withdraws money, you directly update their balance. This is simple but loses information about how the current state was reached.
State: { balance: 100 }
Operation: Withdraw 30
→ UPDATE accounts SET balance = 70 WHERE id = 1
State: { balance: 70 }
Previous state is lost.
Event Sourced
Event sourcing takes a different approach. Instead of storing the current state, you store every change as an immutable event. The current state is computed by replaying all events in sequence.
Events:
1. AccountOpened { balance: 0 }
2. MoneyDeposited { amount: 100 }
3. MoneyWithdrawn { amount: 30 }
Current state (computed): { balance: 70 }
All history preserved.
Core Concepts
Events
Events are immutable facts that happened in the past. They describe something that occurred in your domain, always using past tense naming. Each event should contain enough information to be meaningful on its own, but avoid including redundant data.
class MoneyDeposited
{
public function __construct(
public readonly string $accountId,
public readonly int $amount,
public readonly DateTimeImmutable $occurredAt,
public readonly string $eventId = null,
) {
$this->eventId = $eventId ?? Str::uuid()->toString();
}
}
class MoneyWithdrawn
{
public function __construct(
public readonly string $accountId,
public readonly int $amount,
public readonly DateTimeImmutable $occurredAt,
public readonly string $eventId = null,
) {
$this->eventId = $eventId ?? Str::uuid()->toString();
}
}
Notice that all properties are readonly. Events are immutable once created because they represent historical facts that cannot change.
Event Store
The event store is your append-only database of events. Unlike traditional databases, you never update or delete events. The store must support loading all events for a given stream and appending new events with optimistic concurrency control.
interface EventStore
{
public function append(string $streamId, array $events, int $expectedVersion): void;
public function load(string $streamId): array;
public function loadFrom(string $streamId, int $fromVersion): array;
}
class DatabaseEventStore implements EventStore
{
public function append(string $streamId, array $events, int $expectedVersion): void
{
DB::transaction(function () use ($streamId, $events, $expectedVersion) {
// Optimistic concurrency check
$currentVersion = DB::table('events')
->where('stream_id', $streamId)
->max('version') ?? 0;
if ($currentVersion !== $expectedVersion) {
throw new ConcurrencyException(
"Expected version {$expectedVersion}, got {$currentVersion}"
);
}
// Append events
$version = $expectedVersion;
foreach ($events as $event) {
$version++;
DB::table('events')->insert([
'stream_id' => $streamId,
'version' => $version,
'event_type' => get_class($event),
'payload' => json_encode($event),
'occurred_at' => $event->occurredAt,
'created_at' => now(),
]);
}
});
}
public function load(string $streamId): array
{
return DB::table('events')
->where('stream_id', $streamId)
->orderBy('version')
->get()
->map(fn ($row) => $this->deserialize($row))
->toArray();
}
}
The version check prevents two concurrent operations from creating conflicting events. If another process modified the stream between when you loaded it and when you try to append, the operation fails and you must retry with fresh data.
Aggregates
Aggregates are domain objects that encapsulate business logic and emit events. They validate operations and ensure business rules are enforced before recording events. The aggregate's state is rebuilt by applying each event in sequence.
class BankAccount
{
private string $id;
private int $balance = 0;
private array $pendingEvents = [];
private int $version = 0;
public static function open(string $id, int $initialDeposit): self
{
$account = new self();
$account->recordEvent(new AccountOpened($id, $initialDeposit, now()));
return $account;
}
public function deposit(int $amount): void
{
if ($amount <= 0) {
throw new InvalidArgumentException('Amount must be positive');
}
$this->recordEvent(new MoneyDeposited($this->id, $amount, now()));
}
public function withdraw(int $amount): void
{
if ($amount <= 0) {
throw new InvalidArgumentException('Amount must be positive');
}
if ($amount > $this->balance) {
throw new InsufficientFundsException();
}
$this->recordEvent(new MoneyWithdrawn($this->id, $amount, now()));
}
// Apply events to rebuild state
private function apply(object $event): void
{
match (get_class($event)) {
AccountOpened::class => $this->applyAccountOpened($event),
MoneyDeposited::class => $this->applyMoneyDeposited($event),
MoneyWithdrawn::class => $this->applyMoneyWithdrawn($event),
};
$this->version++;
}
private function applyAccountOpened(AccountOpened $event): void
{
$this->id = $event->accountId;
$this->balance = $event->initialDeposit;
}
private function applyMoneyDeposited(MoneyDeposited $event): void
{
$this->balance += $event->amount;
}
private function applyMoneyWithdrawn(MoneyWithdrawn $event): void
{
$this->balance -= $event->amount;
}
private function recordEvent(object $event): void
{
$this->pendingEvents[] = $event;
$this->apply($event);
}
public function getPendingEvents(): array
{
return $this->pendingEvents;
}
public function clearPendingEvents(): void
{
$this->pendingEvents = [];
}
public static function fromHistory(array $events): self
{
$account = new self();
foreach ($events as $event) {
$account->apply($event);
}
return $account;
}
}
The separation between public methods (which validate and record events) and apply methods (which update state) is crucial. Public methods enforce business rules, while apply methods are pure state transitions that never fail.
Repository
The repository bridges aggregates and the event store. It handles loading aggregates from their event history and persisting new events. This keeps the aggregate focused on business logic rather than persistence concerns.
class BankAccountRepository
{
public function __construct(private EventStore $eventStore) {}
public function load(string $id): BankAccount
{
$events = $this->eventStore->load("account-{$id}");
if (empty($events)) {
throw new AccountNotFoundException($id);
}
return BankAccount::fromHistory($events);
}
public function save(BankAccount $account): void
{
$events = $account->getPendingEvents();
if (empty($events)) {
return;
}
$this->eventStore->append(
"account-{$account->getId()}",
$events,
$account->getVersion() - count($events)
);
$account->clearPendingEvents();
}
}
The expected version calculation ensures no other process has modified this aggregate since we loaded it. This prevents lost updates in concurrent scenarios.
Projections
Read Models
Events are optimized for writes; projections create read-optimized views. While you could always rebuild state from events, that becomes slow as event counts grow. Projections maintain denormalized read models that update as events occur.
class AccountBalanceProjection
{
public function handle(object $event): void
{
match (get_class($event)) {
AccountOpened::class => $this->onAccountOpened($event),
MoneyDeposited::class => $this->onMoneyDeposited($event),
MoneyWithdrawn::class => $this->onMoneyWithdrawn($event),
};
}
private function onAccountOpened(AccountOpened $event): void
{
DB::table('account_balances')->insert([
'account_id' => $event->accountId,
'balance' => $event->initialDeposit,
'updated_at' => $event->occurredAt,
]);
}
private function onMoneyDeposited(MoneyDeposited $event): void
{
DB::table('account_balances')
->where('account_id', $event->accountId)
->increment('balance', $event->amount);
}
private function onMoneyWithdrawn(MoneyWithdrawn $event): void
{
DB::table('account_balances')
->where('account_id', $event->accountId)
->decrement('balance', $event->amount);
}
}
Projections can be rebuilt from scratch by replaying all events. This is powerful for fixing bugs in projection logic or creating new views of existing data.
Multiple Projections
One of event sourcing's strengths is creating multiple specialized views from the same events. Each projection optimizes for different query patterns without affecting the others.
// Same events, different read models
class AccountTransactionHistory
{
public function handle(object $event): void
{
if ($event instanceof MoneyDeposited || $event instanceof MoneyWithdrawn) {
DB::table('transactions')->insert([
'account_id' => $event->accountId,
'type' => $event instanceof MoneyDeposited ? 'deposit' : 'withdrawal',
'amount' => $event->amount,
'occurred_at' => $event->occurredAt,
]);
}
}
}
class DailyAccountSummary
{
public function handle(object $event): void
{
if ($event instanceof MoneyDeposited || $event instanceof MoneyWithdrawn) {
$date = $event->occurredAt->format('Y-m-d');
$type = $event instanceof MoneyDeposited ? 'deposits' : 'withdrawals';
DB::table('daily_summaries')->updateOrInsert(
['account_id' => $event->accountId, 'date' => $date],
[$type => DB::raw("{$type} + {$event->amount}")]
);
}
}
}
The transaction history provides a chronological log, while the daily summary aggregates for reporting. Both are built from the same source of truth but serve different use cases.
Snapshots
For aggregates with many events, snapshots improve load performance. Instead of replaying thousands of events, you can load a snapshot and only replay events since the snapshot was taken.
class SnapshotStore
{
public function save(string $aggregateId, int $version, object $state): void
{
DB::table('snapshots')->updateOrInsert(
['aggregate_id' => $aggregateId],
[
'version' => $version,
'state' => serialize($state),
'created_at' => now(),
]
);
}
public function load(string $aggregateId): ?array
{
$snapshot = DB::table('snapshots')
->where('aggregate_id', $aggregateId)
->first();
if (!$snapshot) {
return null;
}
return [
'version' => $snapshot->version,
'state' => unserialize($snapshot->state),
];
}
}
class BankAccountRepository
{
public function load(string $id): BankAccount
{
$streamId = "account-{$id}";
// Try snapshot first
$snapshot = $this->snapshotStore->load($streamId);
if ($snapshot) {
$account = $snapshot['state'];
// Load only events after snapshot
$events = $this->eventStore->loadFrom($streamId, $snapshot['version']);
} else {
$account = new BankAccount();
$events = $this->eventStore->load($streamId);
}
foreach ($events as $event) {
$account->apply($event);
}
// Create snapshot every 100 events
if ($account->getVersion() % 100 === 0) {
$this->snapshotStore->save($streamId, $account->getVersion(), $account);
}
return $account;
}
}
The snapshot frequency is a tradeoff between storage space and load time. Snapshotting every 100 events means you never need to replay more than 100 events, while keeping snapshot overhead manageable.
When to Use Event Sourcing
Good Fit
- Audit requirements: Financial systems, healthcare, compliance
- Complex domain logic: State machines, business rules
- Temporal queries: "What was the state on date X?"
- Event-driven architecture: Events drive other systems
- Debugging: Replay to reproduce issues
Poor Fit
- Simple CRUD: Overkill for basic operations
- High-frequency updates: Event storage grows quickly
- Simple queries: Projections add complexity
- Small team: Requires expertise to implement well
Common Pitfalls
Large Events
Events should contain only essential data. Including too much information bloats storage and couples events to data that might change. Keep events focused on what actually changed.
// Bad: Too much data in event
class OrderPlaced
{
public array $items; // Could be huge
public array $customerDetails; // Redundant
public array $shippingOptions; // Reference data
}
// Good: Minimal, essential data
class OrderPlaced
{
public string $orderId;
public string $customerId;
public array $itemIds;
public int $totalCents;
}
Reference data like customer details or shipping options should be looked up separately rather than stored in every event.
Missing Events
Every state change must come from an event. If you modify state without recording an event, you break the fundamental guarantee that events are the source of truth. This is a common mistake when adding quick fixes.
// Bad: State change without event
public function applyDiscount(int $percent): void
{
$this->total = $this->total * (100 - $percent) / 100;
// No event recorded!
}
// Good: Every state change from an event
public function applyDiscount(int $percent): void
{
$this->recordEvent(new DiscountApplied($this->id, $percent, now()));
}
Event Schema Evolution
Events are immutable and stored forever, but your code evolves. When you add new fields or change event structure, you need a strategy for handling old events. Upcasters transform old event formats to new ones during deserialization.
// Version 1
class CustomerRegistered
{
public string $name;
}
// Version 2: Added email
class CustomerRegistered
{
public string $name;
public ?string $email; // Nullable for old events
}
// Upcaster for old events
class CustomerRegisteredUpcaster
{
public function upcast(array $payload, int $version): array
{
if ($version < 2) {
$payload['email'] = null;
}
return $payload;
}
}
Making new fields nullable ensures old events deserialize correctly. The upcaster runs transparently during event loading, so your application code only sees the latest event structure.
Conclusion
Event sourcing provides powerful capabilities: complete audit trails, temporal queries, and event replay. But it adds significant complexity compared to traditional CRUD. Use it when you need its specific benefits;audit requirements, complex domains, or event-driven architectures. For simple applications, the overhead isn't justified. Start with traditional approaches and migrate to event sourcing only when the benefits clearly outweigh the costs.