Event sourcing transforms how we think about data persistence in Laravel applications. Instead of storing just the current state, we capture every change as an immutable event, creating a complete audit trail that enables powerful features like time travel debugging and complex business analytics.
For decoupled event-driven architecture, see Laravel Events and Listeners: Building Decoupled Applications.
Table Of Contents
- Understanding Event Sourcing
- Why Event Sourcing in Laravel?
- Core Concepts
- Setting Up Event Sourcing
- Creating Your First Event
- Building Aggregates
- Creating Projections
- Implementing Commands
- Handling Complex Scenarios
- Performance Optimization
- Testing Event-Sourced Systems
- Common Pitfalls and Solutions
- When to Use Event Sourcing
- Conclusion
- Related Posts
Understanding Event Sourcing
Traditional applications update database records directly, losing the history of changes. Event sourcing flips this model – instead of storing state, we store events that represent state changes. The current state becomes a projection of all events that have occurred.
For advanced data handling, check out Laravel Collections: Beyond Basic Array Operations.
Think of it like a bank account. Traditional systems store the balance. Event sourcing stores every deposit, withdrawal, and transfer. The balance is calculated by replaying these events. This approach provides a complete audit trail and enables powerful temporal queries.
Why Event Sourcing in Laravel?
Laravel's event system and queue infrastructure make it an excellent choice for event sourcing. The framework's elegant syntax and powerful features allow us to implement complex event-driven architectures while maintaining code readability.
If you are building for scale, see Building Multi-Tenant Applications with Laravel: A Comprehensive Guide.
Event sourcing shines in domains where audit trails are crucial – financial systems, healthcare applications, e-commerce platforms, and any system where understanding "how we got here" is as important as knowing "where we are."
Core Concepts
Before diving into implementation, let's understand the key components:
Events: Immutable records of something that happened. Once stored, events never change.
Event Store: A specialized database optimized for appending and reading events in sequence.
Aggregates: Domain objects that handle commands and produce events. They encapsulate business logic and ensure consistency.
Projections: Read models built from events. These provide optimized views of your data for queries.
For performance and query optimization, see Advanced Eloquent Techniques and Optimizations in Laravel.
Commands: Represent intentions to change the system. Commands are validated and handled by aggregates.
Setting Up Event Sourcing
Let's implement a simple event sourcing system in Laravel. First, install a package like EventSauce or Spatie's Laravel Event Sourcing:
composer require spatie/laravel-event-sourcing
Publish the configuration and migrations:
php artisan vendor:publish --provider="Spatie\EventSourcing\EventSourcingServiceProvider"
php artisan migrate
Creating Your First Event
Let's build an order management system. Start by creating events:
<?php
namespace App\Events;
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;
class OrderPlaced extends ShouldBeStored
{
public function __construct(
public string $orderId,
public string $customerId,
public array $items,
public float $totalAmount,
public string $placedAt
) {}
}
class OrderShipped extends ShouldBeStored
{
public function __construct(
public string $orderId,
public string $trackingNumber,
public string $shippedAt
) {}
}
class OrderDelivered extends ShouldBeStored
{
public function __construct(
public string $orderId,
public string $deliveredAt
) {}
}
Building Aggregates
Aggregates handle commands and produce events. They contain your business logic:
<?php
namespace App\Aggregates;
use App\Events\OrderPlaced;
use App\Events\OrderShipped;
use App\Events\OrderDelivered;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;
class OrderAggregate extends AggregateRoot
{
protected string $orderId;
protected string $status = 'pending';
protected bool $shipped = false;
public function placeOrder(
string $customerId,
array $items,
float $totalAmount
): self {
if ($this->status !== 'pending') {
throw new \Exception('Order already placed');
}
$this->recordThat(new OrderPlaced(
orderId: $this->uuid(),
customerId: $customerId,
items: $items,
totalAmount: $totalAmount,
placedAt: now()->toIso8601String()
));
return $this;
}
public function shipOrder(string $trackingNumber): self
{
if ($this->status !== 'placed') {
throw new \Exception('Order must be placed before shipping');
}
if ($this->shipped) {
throw new \Exception('Order already shipped');
}
$this->recordThat(new OrderShipped(
orderId: $this->uuid(),
trackingNumber: $trackingNumber,
shippedAt: now()->toIso8601String()
));
return $this;
}
public function markAsDelivered(): self
{
if (!$this->shipped) {
throw new \Exception('Order must be shipped before delivery');
}
$this->recordThat(new OrderDelivered(
orderId: $this->uuid(),
deliveredAt: now()->toIso8601String()
));
return $this;
}
// Apply methods update aggregate state from events
protected function applyOrderPlaced(OrderPlaced $event): void
{
$this->orderId = $event->orderId;
$this->status = 'placed';
}
protected function applyOrderShipped(OrderShipped $event): void
{
$this->shipped = true;
$this->status = 'shipped';
}
protected function applyOrderDelivered(OrderDelivered $event): void
{
$this->status = 'delivered';
}
}
Creating Projections
Projections build read models from events. They're optimized for queries:
<?php
namespace App\Projectors;
use App\Events\OrderPlaced;
use App\Events\OrderShipped;
use App\Events\OrderDelivered;
use App\Models\Order;
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;
class OrderProjector extends Projector
{
public function onOrderPlaced(OrderPlaced $event): void
{
Order::create([
'id' => $event->orderId,
'customer_id' => $event->customerId,
'items' => json_encode($event->items),
'total_amount' => $event->totalAmount,
'status' => 'placed',
'placed_at' => $event->placedAt,
]);
}
public function onOrderShipped(OrderShipped $event): void
{
Order::find($event->orderId)->update([
'tracking_number' => $event->trackingNumber,
'status' => 'shipped',
'shipped_at' => $event->shippedAt,
]);
}
public function onOrderDelivered(OrderDelivered $event): void
{
Order::find($event->orderId)->update([
'status' => 'delivered',
'delivered_at' => $event->deliveredAt,
]);
}
}
Implementing Commands
Commands represent user intentions. Use Laravel's command bus or create a simple handler:
<?php
namespace App\Commands;
use App\Aggregates\OrderAggregate;
class PlaceOrderHandler
{
public function handle(PlaceOrderCommand $command): void
{
OrderAggregate::retrieve($command->orderId)
->placeOrder(
$command->customerId,
$command->items,
$command->totalAmount
)
->persist();
}
}
class ShipOrderHandler
{
public function handle(ShipOrderCommand $command): void
{
OrderAggregate::retrieve($command->orderId)
->shipOrder($command->trackingNumber)
->persist();
}
}
Handling Complex Scenarios
Event sourcing excels at complex business scenarios. Consider implementing:
Compensating Events: Handle cancellations and refunds by adding compensating events rather than deleting data.
class OrderCancelled extends ShouldBeStored
{
public function __construct(
public string $orderId,
public string $reason,
public string $cancelledAt
) {}
}
Temporal Queries: Reconstruct state at any point in time:
// Get order state as of specific date
$orderAtDate = OrderAggregate::retrieve($orderId)
->reconstituteFromEvents(
$events->filter(fn($event) => $event->created_at <= $date)
);
Event Versioning: Handle schema changes gracefully:
class OrderPlacedV2 extends OrderPlaced
{
public function __construct(
string $orderId,
string $customerId,
array $items,
float $totalAmount,
string $placedAt,
public ?string $couponCode = null
) {
parent::__construct($orderId, $customerId, $items, $totalAmount, $placedAt);
}
}
Performance Optimization
Event sourcing can accumulate many events. Optimize performance with:
Snapshots: Periodically save aggregate state to avoid replaying all events:
class OrderAggregateSnapshot
{
public function __construct(
public string $aggregateId,
public array $state,
public int $aggregateVersion
) {}
}
Read Model Indexes: Optimize projections for common queries:
Schema::table('orders', function (Blueprint $table) {
$table->index(['customer_id', 'status']);
$table->index('placed_at');
});
Event Stream Partitioning: Split events across multiple streams for better performance.
Testing Event-Sourced Systems
Testing becomes more straightforward with event sourcing:
public function test_order_can_be_placed()
{
// Given
$aggregate = OrderAggregate::fake();
// When
$aggregate->placeOrder('customer-1', [
['product' => 'Widget', 'quantity' => 2]
], 99.99);
// Then
$aggregate->assertRecorded([
new OrderPlaced(
orderId: $aggregate->uuid(),
customerId: 'customer-1',
items: [['product' => 'Widget', 'quantity' => 2]],
totalAmount: 99.99,
placedAt: now()->toIso8601String()
)
]);
}
Common Pitfalls and Solutions
Event Granularity: Don't create events for every field change. Group related changes into meaningful business events.
Projection Consistency: Use database transactions when updating multiple projections from a single event.
Event Naming: Use past tense for events (OrderPlaced, not PlaceOrder) to indicate something has happened.
Performance: Monitor event store size and implement archiving strategies for old events.
When to Use Event Sourcing
Event sourcing isn't always the right choice. Use it when:
- Audit trails are legally required
- You need to understand how data changed over time
- Complex business workflows require temporal queries
- Multiple teams need different views of the same data
- You're building financial or compliance-heavy applications
Avoid it for:
- Simple CRUD applications
- Systems with minimal audit requirements
- Projects with tight deadlines and inexperienced teams
Conclusion
Event sourcing in Laravel opens up powerful possibilities for building robust, auditable applications. While it requires a mindset shift from traditional CRUD operations, the benefits – complete audit trails, temporal queries, and event-driven architecture – make it invaluable for complex domains.
Start small with a single aggregate, understand the patterns, then gradually expand. The Laravel ecosystem provides excellent tools to implement event sourcing without overwhelming complexity. Remember, event sourcing is a tool – use it where it provides clear business value.
Add Comment
No comments yet. Be the first to comment!