Navigation

Laravel

Event Sourcing with Laravel

Master event sourcing in Laravel with this comprehensive guide. Learn to implement event stores, aggregates, projections, and CQRS patterns. Build auditable applications with complete history tracking and temporal queries using Laravel's powerful event system.
Event Sourcing with Laravel

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

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.


Related Posts

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Laravel