Why Roll Your Own (At Least Partially)
Packages like spatie/laravel-event-sourcing are excellent, but they carry opinions about storage, snapshots, and aggregate retrieval that can fight your domain. Understanding the primitives lets you adopt a package selectively — or build a lightweight version that fits your bounded context perfectly.
This article focuses on three concepts: aggregates (the write side), projectors (read-model builders), and reactors (side-effect handlers).
The Event Store
Every event needs a durable home before anything else happens.
// database/migrations/xxxx_create_stored_events_table.php
Schema::create('stored_events', function (Blueprint $table) {
$table->id();
$table->uuid('aggregate_uuid')->index();
$table->string('aggregate_type');
$table->string('event_class');
$table->jsonb('payload');
$table->unsignedBigInteger('aggregate_version');
$table->timestamps();
$table->unique(['aggregate_uuid', 'aggregate_version']);
});
The unique constraint on (aggregate_uuid, aggregate_version) is your optimistic concurrency guard — two concurrent writes for the same version will produce a database-level conflict rather than silent data corruption.
The Aggregate Root
abstract class AggregateRoot
{
private array $recordedEvents = [];
protected int $aggregateVersion = 0;
public static function retrieve(string $uuid): static
{
$instance = new static($uuid);
$events = StoredEvent::forAggregate($uuid)->get();
foreach ($events as $stored) {
$event = unserialize($stored->payload['serialized']);
$instance->apply($event);
$instance->aggregateVersion = $stored->aggregate_version;
}
return $instance;
}
protected function recordThat(DomainEvent $event): void
{
$this->apply($event);
$this->recordedEvents[] = $event;
}
public function persist(): void
{
foreach ($this->recordedEvents as $event) {
$this->aggregateVersion++;
StoredEvent::create([
'aggregate_uuid' => $this->uuid,
'aggregate_type' => static::class,
'event_class' => $event::class,
'payload' => ['serialized' => serialize($event)],
'aggregate_version' => $this->aggregateVersion,
]);
event($event); // dispatch to projectors & reactors
}
$this->recordedEvents = [];
}
abstract protected function apply(DomainEvent $event): void;
}
A concrete aggregate looks like this:
final class OrderAggregate extends AggregateRoot
{
private OrderStatus $status;
public function place(CustomerId $customer, Money $total): void
{
// guard business rules here
$this->recordThat(new OrderPlaced($this->uuid, $customer, $total));
}
public function cancel(string $reason): void
{
if ($this->status === OrderStatus::Shipped) {
throw new CannotCancelShippedOrder();
}
$this->recordThat(new OrderCancelled($this->uuid, $reason));
}
protected function apply(DomainEvent $event): void
{
match (true) {
$event instanceof OrderPlaced => $this->status = OrderStatus::Pending,
$event instanceof OrderCancelled => $this->status = OrderStatus::Cancelled,
default => null,
};
}
}
Projectors: Building Read Models
Projectors are plain Laravel listeners. They rebuild query-optimised tables from events.
final class OrderListProjector
{
public function onOrderPlaced(OrderPlaced $event): void
{
OrderReadModel::create([
'uuid' => $event->orderId,
'customer_id' => $event->customerId->value(),
'total_cents' => $event->total->cents(),
'status' => 'pending',
]);
}
public function onOrderCancelled(OrderCancelled $event): void
{
OrderReadModel::where('uuid', $event->orderId)
->update(['status' => 'cancelled']);
}
}
Register it in EventServiceProvider:
protected $listen = [
OrderPlaced::class => [OrderListProjector::class . '@onOrderPlaced'],
OrderCancelled::class => [OrderListProjector::class . '@onOrderCancelled'],
];
Reactors: Side Effects in Isolation
Reactors handle side effects — emails, webhooks, third-party calls — and should always run asynchronously.
final class NotifyCustomerOnCancellation implements ShouldQueue
{
public function handle(OrderCancelled $event): void
{
Mail::to($event->customerEmail)->send(new OrderCancelledMail($event));
}
}
Because reactors are queued, a mail failure never rolls back your aggregate state. That separation is the point.
Replaying Projections
The killer feature of event sourcing is replay. Drop a corrupted read model and rebuild it:
artisan make:command ReplayProjection
// inside handle()
StoredEvent::query()
->whereIn('event_class', [OrderPlaced::class, OrderCancelled::class])
->chunkById(500, function ($chunk) {
foreach ($chunk as $stored) {
$event = unserialize($stored->payload['serialized']);
(new OrderListProjector())->{'on' . class_basename($event)}($event);
}
});
Key Takeaways
- The unique version constraint is your concurrency guard — don't skip it.
- Aggregates own business rules; projectors own read models; reactors own side effects. Keep them separate.
- Replay is the payoff — design projectors to be idempotent from day one.
- Serialize carefully: use versioned DTOs or JSON, not raw PHP
serialize(), in production. - Start with one bounded context before applying event sourcing everywhere.