BlogEvent sourcing in Laravel: benefits, events, aggregates, and projections

Event sourcing in Laravel: benefits, events, aggregates, and projections

Event sourcing becomes interesting in Laravel as soon as you want more than just storing the current state of a record. Instead of only saving the latest state, you store each relevant intent as an event. That gives you a complete history, makes debugging easier, and lets you build more complex domain logic without your database tables becoming heavier over time.

Why event sourcing in Laravel is beneficial

The biggest advantage is insight. You do not just see that an order or subscription is currently active, but also which steps led to that state. That helps with audits, debugging, and reporting.

Event sourcing also makes it easier to separate write logic from read logic. Your intents live in aggregates and events, while projections build optimized tables for queries in your application. In SaaS platforms or back offices with many state transitions, that creates much more clarity in your codebase.

You can also use the stored intents later to create reports that did not exist earlier in the project. In that sense, event sourcing keeps producing new insights even as the product grows.

Installing Spatie event sourcing with Composer

For Laravel, Spatie's package is a pragmatic choice. You install it with Composer:

composer require spatie/laravel-event-sourcing

Then publish the migrations and configuration files and run the migrations:

php artisan vendor:publish --provider="Spatie\EventSourcing\EventSourcingServiceProvider" --tag="event-sourcing-migrations"
php artisan vendor:publish --provider="Spatie\EventSourcing\EventSourcingServiceProvider" --tag="event-sourcing-config"
php artisan migrate

This gives you the tables for the stored events. From that point on, Laravel can persist your events durably and replay them whenever necessary.

Setting up projection models

A projection model is the model your application uses for screens, filters, and exports. In many projects, this is a regular Eloquent table that is updated by events. If you want to track subscriptions, for example, you could create a projection model for subscriptions like this:

<?php
declare(strict_types=1);

namespace App\Models;

class Subscription extends Projection
{
    protected $guarded = [];
}

This model is intentionally very minimal; all intents flow through the aggregate. All mutations happen inside the projector. If your projector and events are set up correctly, every relevant change is reflected from those intents. Projections also cannot be mutated unless you explicitly call ->writeable(), so a mutation remains a deliberate choice.

Creating events

Define the events that matter in your domain. Think of a user registering, a subscription starting, or an invoice being paid. An event should only contain the data required to describe that domain occurrence:

<?php
declare(strict_types=1);

namespace App\Subscriptions\Events;

use Spatie\EventSourcing\StoredEvents\ShouldBeStored;

class SubscriptionCreated extends ShouldBeStored
{
    public function __construct(
        public readonly string $uuid,
        public readonly string $email,
        public readonly string $plan,
    ) {
      //
    }
}

The key difference from a standard Laravel event is that these events are stored as the source of truth. Your model state follows from the events, not the other way around. That is why the event should contain all required data in its constructor. The projector can then reflect the change based on those properties.

Using aggregates for domain logic

An aggregate determines which events may or may not happen. This is where business logic belongs, such as validating a state transition or preventing duplicate actions. In Spatie, you typically model that with an aggregate root:

<?php
declare(strict_types=1);

namespace App\Aggregates;

use App\Events\Subscription\SubscriptionCreated;
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

class SubscriptionAggregate extends AggregateRoot
{
    public function create(string $uuid, string $email, string $plan): self
    {
        $this->recordThat(new SubscriptionCreated(
            uuid: $uuid,
            email: $email,
            plan: $plan,
        ));

        return $this;
    }
}

From an action, controller, or service, you then load the aggregate by UUID, execute the domain action, and persist the new events. That keeps complex decisions out of your controllers.

Updating projections from events

Finally, connect a projector to your events so your projection models are updated automatically. This translates domain events into a read model that is ideal for dashboards and index pages:

<?php
declare(strict_types=1);

namespace App\Projectors;

use App\Domain\Subscriptions\Events\SubscriptionCreated;
use App\Models\Subscription;
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;

class SubscriptionProjector extends Projector
{
    public function onSubscriptionCreated(SubscriptionCreated $event): void
    {
        (new Subscription)
            ->writeable()
            ->fill([
                'uuid' => $event->uuid,
                'email' => $event->email,
                'plan' => $event->plan,
                'status' => 'active',
            ])
            ->save();
    }
}

This is where event sourcing scales well. You can add extra projections later for reporting, notifications, or audit overviews without redesigning your domain logic. Your events stay the same, while the derived models can keep evolving with new product requirements.

When this is a good choice in Laravel

Event sourcing is not necessary for every CRUD project. But as soon as you deal with many state transitions, audit requirements, integrations, or complex business rules, it can become a strong foundation. You gain much better visibility into what is happening in the system and keep your domain logic explicit and testable.

At Snoeren Development, we use Spatie Event Sourcing frequently to store not only the current state, but also the path that led to it. That creates a lot of insight and improves system safety. If you store all intents instead of only mutations, you get a much clearer picture of how your application behaves. For example, you can store that a user tried to perform an action three times and build logic around that behavior.