DEV Community

Freek Van der Herten
Freek Van der Herten

Posted on • Originally published at freek.dev on

★ Introducing laravel-event-projector: the easiest way to get started with event sourcing in a Laravel app

In most applications you store the state of the application in the database. If something needs to change you simply update values in a table.

When using event sourcing you'll take a different approach. All changes to application state are stored as a series of events. The key benefit is that you now have a history of your database. You can derive any data your application needs using those stored events.

There are already a couple of very well written PHP packages, like prooph or EventSauce, that can bring the power of event sourcing to your app. But if you've never worked with event sourcing getting started with these package can be quite hard.

I felt like there should be an easier entry point for Laravel users to get started with event sourcing. That's why these past few months I invested some time in creating a new package called laravel-event-projector. It was released a couple of days ago.

The package allows you to easily store events, create projections and reactors (you'll learn what those things are a bit further in this post), replaying events and much more.

In this post I'd like to introduce the package to you.

The traditional example

Let's first make this idea behind event source more clear with a practical example. Imagine you are a bank with customers that have accounts. All these accounts have a balance. When money gets added or subtracted we could modify the balance. If we do that however, we would never know why the balance got to that number. If we were to store all the events we could calculate the balance.

Using those stored events we can do a few other interesting things. Image the managers of the bank are, after some years, interested a report of the average balance of each account in the past year. If you would have stored only the balance itself you wouldn't able to generate that report. But using the stored events you could generate such a report easily.

Chances are that you don't implement account balance systems for banks from scratch, so the example above can be a little bit far fetched. But you can use the idea in any kind of project. Image you're building an e-commerce site. You can build up the contents of a cart and orders using event sourcing. There are a lot of reports you could generate with those events.

Introducing laravel-event-projector

I've created a short video that explains the high level concepts of the package.

What are projectors?

In the video I touched upon the concept of a projector. A projector is a class that gets triggered when new events come in. A projectors writes data to storage (it could be a to a database or to a file on disk. We call that written data a projection. Let's dive into some code and learn how to write a projector.

Imagine you are a bank with customers that have accounts. All these accounts have a balance. When money gets added or subtracted we could modify the balance. If we were to do that however, we would never know why the balance got to that number. If we were to store all the transactions as events we could calculate the balance.

Creating a model

Here's a small migration to create a table that stores accounts. Using a uuid is not strictly required, but it will make your life much easier when using this package. In all examples we'll assume that you'll use them.

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateAccountsTable extends Migration
{
    public function up()
    {
        Schema::create('accounts', function (Blueprint $table) {
            $table->increments('id');
            $table->string('uuid');
            $table->string('name');
            $table->integer('balance')->default(0);
            $table->timestamps();
        });
    }
}

The Account model itself could look like this:

namespace App;

use App\Events\AccountCreated;
use App\Events\AccountDeleted;
use App\Events\MoneyAdded;
use App\Events\MoneySubtracted;
use Illuminate\Database\Eloquent\Model;
use Ramsey\Uuid\Uuid;

class Account extends Model
{
    protected $guarded = [];

    protected $casts = [
        'broke_mail_send' => 'bool',
    ];

    public static function createWithAttributes(array $attributes): Account
    {
        /*
         * Let's generate a uuid. 
         */
        $attributes['uuid'] = (string) Uuid::uuid4();

        /*
         * The account will be created inside this event using the generated uuid.
         */
        event(new AccountCreated($attributes));

        /*
         * The uuid will be used the retrieve the created account.
         */
        return static::uuid($attributes['uuid']);
    }

    public function addMoney(int $amount)
    {
        event(new MoneyAdded($this->uuid, $amount));
    }

    public function subtractMoney(int $amount)
    {
        event(new MoneySubtracted($this->uuid, $amount));
    }

    public function delete()
    {
        event(new AccountDeleted($this->uuid));
    }

    /*
     * A helper method to quickly retrieve an account by uuid.
     */
    public static function uuid(string $uuid): ?Account
    {
        return static::where('uuid', $uuid)->first();
    }
}

Defining events

Instead of creating, updating and deleting accounts, we're simply firing off events. All these events should implement \Spatie\EventProjector\ShouldBeStored. This is an empty interface that signifies to our package that the event should be stored.

Let's take a look at all events used in the Account model.

namespace App\Events;

use Spatie\EventProjector\ShouldBeStored;

class AccountCreated implements ShouldBeStored
{
    /** @var array */
    public $accountAttributes;

    public function __construct(array $accountAttributes)
    {
        $this->accountAttributes = $accountAttributes;
    }
}

namespace App\Events;

use Spatie\EventProjector\ShouldBeStored;

class MoneyAdded implements ShouldBeStored
{
    /** @var string */
    public $accountUuid;

    /** @var int */
    public $amount;

    public function __construct(string $accountUuid, int $amount)
    {
        $this->accountUuid = $accountUuid;

        $this->amount = $amount;
    }
}

namespace App\Events;

use Spatie\EventProjector\ShouldBeStored;

class MoneySubtracted implements ShouldBeStored
{
    /** @var string */
    public $accountUuid;

    /** @var int */
    public $amount;

    public function __construct(string $accountUuid, int $amount)
    {
        $this->accountUuid = $accountUuid;

        $this->amount = $amount;
    }
}

namespace App\Events;

use Spatie\EventProjector\ShouldBeStored;

class AccountDeleted implements ShouldBeStored
{
    /** @var string */
    public $accountUuid;

    public function __construct(string $accountUuid)
    {
        $this->accountUuid = $accountUuid;
    }
}

Creating your first projector

A projector is a class that listens for events that were stored. When it hears an event that it is interested in, it can perform some work.

Let's create your first projector. You can perform php artisan make:projector AccountBalanceProjector to create a projector in app\Projectors.

Here's an example projector that handles all the events mentioned above:

namespace App\Projectors;

use App\Account;
use App\Events\AccountCreated;
use App\Events\AccountDeleted;
use App\Events\MoneyAdded;
use App\Events\MoneySubtracted;
use Spatie\EventProjector\Models\StoredEvent;
use Spatie\EventProjector\Projectors\Projector;
use Spatie\EventProjector\Projectors\ProjectsEvents;

class AccountBalanceProjector implements Projector
{
    use ProjectsEvents;

    /*
     * Here you can specify which event should trigger which method.
     */
    public $handlesEvents = [
        AccountCreated::class => 'onAccountCreated',
        MoneyAdded::class => 'onMoneyAdded',
        MoneySubtracted::class => 'onMoneySubtracted',
        AccountDeleted::class => 'onAccountDeleted',
    ];

    public function onAccountCreated(AccountCreated $event)
    {
        Account::create($event->accountAttributes);
    }

    public function onMoneyAdded(MoneyAdded $event)
    {
        $account = Account::uuid($event->accountUuid);

        $account->balance += $event->amount;

        $account->save();
    }

    public function onMoneySubtracted(MoneySubtracted $event)
    {
        $account = Account::uuid($event->accountUuid);

        $account->balance -= $event->amount;

        $account->save();

        if ($account->balance >= 0) {
            $this->broke_mail_sent = false;
        }
    }

    public function onAccountDeleted(AccountDeleted $event)
    {
        Account::uuid($event->accountUuid)->delete();
    }
}

Registering your projector

The projector code up above will update the accounts table based on the fired events.

Projectors need to be registered. The easiest way to register a projector is by calling addProjector on the EventProjectionist class. Typically you would put this in a service provider of your own.

use Illuminate\Support\ServiceProvider;
use App\Projectors\AccountBalanceProjector;
use Spatie\EventProjector\EventProjectionist;

class AppServiceProvider extends ServiceProvider
{
    public function boot(EventProjectionist $eventProjectionist)
    {
        $eventProjectionist->addProjector(AccountBalanceProjector::class);
    }
}

You can also use the EventProjectionist facade.

use App\Projectors\AccountBalanceProjector;
use Spatie\EventProjector\Facades\EventProjectionist;

EventProjectionist::addProjector(AccountBalanceProjector::class);

Let's fire off some events

With all this out of the way we can fire off some events.

Let's try adding an account with:

Account::createWithAttributes(['name' => 'Luke']);
Account::createWithAttributes(['name' => 'Leia']);

And let's make some transactions on that account:

$account = Account::where(['name' => 'Luke'])->first();
$anotherAccount = Account::where(['name' => 'Leia'])->first();

$account->addMoney(1000);
$anotherAccount->addMoney(500);
$account->subtractMoney(50);

If you take a look at the contents of the accounts table you should see some accounts together with their calculated balance. Sweet! In the stored_events table you should see an entry per event that we fired.

Your second projector

Imagine that, after a while, someone at the bank wants to know which accounts have processed the most transactions. Because we stored all changes to the accounts in the events table we can easily get that info by creating another projector.

We are going to create another projector that stores the transaction count per account in a model. Bear in mind that you can easily use any other storage mechanism instead of a model. The projector doesn't care what you use.

Here's the migration and the model class that the projector is going to use:

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateTransactionCountsTable extends Migration
{
    public function up()
    {
        Schema::create('transaction_counts', function (Blueprint $table) {
            $table->increments('id');
            $table->string('account_uuid');
            $table->integer('count')->default(0);
            $table->timestamps();
        });
    }
}

If you're following along don't forget to run this new migration.

php artisan migrate

namespace App;

use Illuminate\Database\Eloquent\Model;

class TransactionCount extends Model
{
    public $guarded = [];
}

Here's the projector that is going to listen to the MoneyAdded and MoneySubtracted events:

namespace App\Projectors;

use App\Events\MoneyAdded;
use App\Events\MoneySubtracted;
use App\TransactionCount;
use Spatie\EventProjector\Models\StoredEvent;
use Spatie\EventProjector\Projectors\Projector;
use Spatie\EventProjector\Projectors\ProjectsEvents;

class TransactionCountProjector implements Projector
{
    use ProjectsEvents;

    public $handlesEvents = [
        MoneyAdded::class => 'onMoneyAdded',
        MoneySubtracted::class => 'onMoneySubtracted',
    ];

    public function onMoneyAdded(MoneyAdded $event)
    {
        $transactionCounter = TransactionCount::firstOrCreate(['account_uuid' => $event->accountUuid]);

        $transactionCounter->count += 1;

        $transactionCounter->save();
    }

    public function onMoneySubtracted(MoneySubtracted $event)
    {
        $transactionCounter = TransactionCount::firstOrCreate(['account_uuid' => $event->accountUuid]);

        $transactionCounter->count += 1;

        $transactionCounter->save();
    }
}

Let's not forget to register this projector:

// in a service provider of your own
EventProjectionist::addProjector(TransactionCountProjector::class);

If you've followed along, you've already created some accounts and some events. To feed those past events to the projector we can simply perform this artisan command:

php artisan event-projector:replay

This command will take all events stored in the stored_events table and pass them to TransactionCountProjector. After the command completes you should see the transaction counts in the transaction_counts table.

Welcoming new events

Now that both of your projections have handled all events, try firing off some new events.

Account::createWithAttributes(['name' => 'Yoda']);

Let's add some transactions to that account:

$yetAnotherAccount = Account::where(['name' => 'Yoda'])->first();

$yetAnotherAccount->addMoney(1000);
$yetAnotherAccount->subtractMoney(50);

You'll notice that both projectors are immediately handling these new events. The balance of the Account model is up to date and the data in the transaction_counts table gets updated.

Benefits of projectors and projections

The cool thing about projectors is that you can write them after events have happened. Imagine that someone at the bank wants to have a report of the average balance of each account. You would be able to write a new projector, replay all events, and have that data.

Projections are very fast to query. Image that our application has processed millions of events. If you want to create a screen where you display the accounts with the most transactions you can easily query the transaction_counts table. This way you don't need to fire off some expensive query. The projector will keep the projections (the transaction_counts table) up to date.

Handling side effects with reactors

Now that you've written your first projector, let's learn how to handle side effects. With side effects we mean things like sending a mail, sending a notification, ... You only want to perform these actions when the original event happens. You don't want to do this work when replaying events.

A reactor is a class, that much like a projector, listens for incoming events. Unlike projectors however, reactors will not get called when events are replayed. Reactors only will get called when the original event fires.

In the example above we used an Eloquent model as the storage mechanism for our projection, but you can use any form of storage that your want. Keep in mind however, that you only change this projection using a projector. If you were to update the model from another place, these changes would get lost when replaying events.

Creating your first reactor

Let's create your first reactor. You can perform php artisan make:reactor BigAmountAddedReactor to create a reactor in app\Reactors. We will make this reactor send a mail to the director of the bank whenever a big amount of money is added to an account.

namespace App\Reactors;

use App\Account;
use App\Events\MoneyAdded;
use App\Mail\BigAmountAddedMail;
use Illuminate\Support\Facades\Mail;

class BigAmountAddedReactor
{
    /*
     * Here you can specify which event should trigger which method.
     */
    protected $handlesEvents = [
        MoneyAdded::class => 'onMoneyAdded',
    ];

    public function onMoneyAdded(MoneyAdded $event)
    {
        if ($event->amount < 900) {
            return;
        }

        $account = Account::find($event->accountId);

        Mail::to('director@bank.com')->send(new BigAmountAddedMail($account, $event->amount));
    }
}

Registering your reactor

For the package to be able to locate the reactor you should register it. The easiest way to register a projector is by calling addReactor on the EventProjectionist facade. Typically you would put this in a service provider of your own.

use \Spatie\EventProjector\Facades\EventProjectionist;
use \App\Reactor\BigAmountAddedReactor;

...

EventProjectionist::addReactor(BigAmountAddedReactor::class)

Using the reactor

The reactor above will send an email to the director of the bank whenever an amount of 900 or more gets added to an account. Let's put the reactor to work.

$account = Account::createWithAttributes(['name' => 'Rey']);
$account->addMoney(1000);

A mail will be sent to the director.

If you truncate the accounts table and rebuild the contents with

php artisan event-projector:replay

no mail will be sent.

Thinking in events

Image you are now tasked with sending a mail to an account holder whenever he or she is broke. You might think, that's easy, let's just check in a new reactor if the account balance is less than zero.

Let's first add a little helper method to the Account model to check if an account is broke.

// ...

class Account extends Model

    // ...

    public function isBroke(): bool
    {
        return $this->balance < 0;
    }
}

Now create a new reactor called BrokeReactor:

namespace App\Reactors;

// ...

class BrokeReactor implements EventHandler
{
    use HandlesEvents;

    public $handlesEvents = [
        MoneySubtracted::class => 'onMoneySubtracted',
    ];

    public function onMoneySubtracted(MoneySubtracted $event)
    {
        $account = Account::uuid($event->accountUuid);

        if ($account->isBroke()) {
            Mail::to($account->email)->send(new BrokeMail($account));

            event(new BrokeMailSent($account->uuid));
        }
    }
}

A mail will get sent when an account is broke. The problem with this approach is that mails will also get sent for accounts that were already broke before. If you want to only sent mail when an account went from a positive balance to a negative balance we need to do some more work.

You might be tempted to add some kind of flag here that determines if the mail was already sent.

But you should never let reactors write to models (or whatever storage mechanism you use) you've built up using projectors. If you were to do that, all changes would get lost when replaying events: events won't get passed to reactors when replaying them. Keep in mind that reactors are meant for side effects, not for building up state.

If you are tempted to modify state in a reactor, just fire off a new event and let a projector modify the state. Let's modify the BrokeReactor to do just that. If you're following along don't forget to create migration that adds the broke_mail_sent field to the accounts table.

// ...

class BrokeReactor implements EventHandler
{
    use HandlesEvents;

    public $handlesEvents = [
        MoneySubtracted::class => 'onMoneySubtracted',
        BrokeMailSent::class => 'onBrokeMailSent',
    ];

    public function onMoneySubtracted(MoneySubtracted $event)
    {
        $account = Account::uuid($event->accountUuid);

        /*
         * Don't send a mail if an account isn't broke
         */
        if (! $account->isBroke()) {
            return;
        }

        /*
         * Don't send a mail if it was sent already
         */
        if ($account->broke_mail_sent) {
            return;
        }

        Mail::to($account->email)->send(new BrokeMail($account));

        /*
         * Send out an event so the projector can modify the state.
         */
        event(new BrokeMailSent($account->uuid));
    }
}

Let's leverage that new event in the AccountBalanceProjector.

// ...

class AccountBalanceProjector implements Projector
{
    // ..

    public function onBrokeMailSent(BrokeMailSent $event)
    {
        $account = Account::uuid($event->accountUuid);

        $account->broke_mail_sent = true;

        $account->save();
    }

    public function onMoneyAdded(MoneyAdded $event)
    {
        $account = Account::uuid($event->accountUuid);

        $account->balance += $event->amount;

        /*
         * If the balance is above zero again, set the broke_mail_sent
         * flag to false again, so we can send another mail
         * when the balance goes below zero again.
         */
        if ($account->balance >= 0) {
            $account->broke_mail_sent = false;
        }

        $account->save();
    }
}

With this in place only a projector will save state. The BrokeReactor will only send out a mail when an account goes broke. No mails will be sent if the account was already broke. When the account goes above zero and goes broke again a new mail will be sent. When replaying all events, no mail will get sent, but all account state will be correct.

Handling events in a queue

A queue can be used to guarantee that all events get passed to projectors in the right order. If you want a projector to handle events in a queue, you should let your projector implement the Spatie\EventProjector\Projectors\QueuedProjector interface instead of the the normal Spatie\EventProjector\Projectors\Projector. This interface merely hints to the package that event handling should happen in a queued manner.

A rule of thumb is that if your projectors aren't producing data that is consumed in the same request as the event are fire, you should let your projector implement QueuedProjector.

Alternatives

laravel-event-projector is probably the easiest way of getting started with event sourcing in Laravel. It's tightly coupled beautifully integrated so it uses Eloquent models, the native queue system, ... If you want to event source without being tied to Laravel so much take a look at these alternatives:

While laravel-event-projector is very pragmatic, the above alternatives are a bit stricter and provide some more concepts such as aggregates, cqrs, ...

Closing off

I hope you liked this tour of the package. Every code snippet from this blogpost is taken from a demo app you'll find in this repo on GitHub.

If you want to know more about event sourcing in general, here are few resources for you to check out:

If you like the laravel-event-projector, take a look at all the other packages my team and I have created.

Top comments (0)