Laravel event projector v2 has been released
Laravel event projector is a package that aims to be the entry point for event sourcing in Laravel. It can help you setting up aggregates, projectors and reactors. Earlier today our team released v2. This version streamlines the projectors and reactors from v1 and adds support for aggregates.
In this blogpost I'd like to explain a potential problem with traditionally built applications. After that we'll take a look at what projectors and aggregates are. Finally we'll walk through an example how you can create an aggregate laravel-event-projector.
Let's go!
Are you a visual learner?
Here's a video that shows what event sourcing is and how you can work with Laravel Event Projector. Don't like videos, then continue reading the next section of this blogpost.
The traditional application
In a traditional application, you're probably going to use a database to hold the state of your application. Whenever you want to update a bit a state, you're simply going to overwrite the old value. That old value isn't accessible anymore. Your application only holds the current state.
You might think that you still have the old state inside your backups. But they don't count. Your app probably can't, nor should it, make decisions on data inside those backups.
First, we write value X.
Next, we overwrite X by Y. X cannot be accessed anymore.
Here's a demo application that uses a traditional architecture. Inside the AccountsController
we are just going to create new accounts and update the balance.
// in the AccountsController
public function store(Request $request)
{
Account::create([
'name' => $request->name,
'user_id' => Auth::user()->id,
]);
return back();
}
We're using an eloquent model to update the database. Whenever we change the balance of the account, the old value is lost.
Using projectors to transform events
Instead of directly updating the value in the database, we could write every change we want to make as an event in our database.
Let's write our first event in the database.
When new events come in, we'll write them to the events table as well.
All events get passed to a class we call a projector. The projector transforms the events to a format that is handy to use in our app. Going back to our example, the events table hold the info of the individual transactions like MoneyAdded
and MoneySubtracted
. A projector could build an Accounts
table based on those transactions.
Imagine that you've already stored some events, and your first projector is doing its job creating that Accounts
table. The bank directory now wants to know on which accounts the most transactions were performed. No problem, we could create another projector that reads all previous events and acts the MoneyAdded
and MoneySubtracted
to make projections.
Our laravel-event-projector package can help you store native Laravel events in a stored_events
table and create projectors that transform those events.
Here's our example app Larabank rebuild with projectors. In the AccountsController
we're not going to directly modify the database anymore. Instead, the controller will call methods than in their turn fire off events.
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));
}
Our package will listen for those events (which implement the empty ShouldBeStored
interface) and store them in the stored_events
table. Those events will also get passed to all registered projectors. In the AccountsProjector
will build the Accounts
table using a couple of events it listens for.
final class AccountsProjector implements Projector
{
use ProjectsEvents;
protected $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;
if ($account->balance >= 0) {
$this->broke_mail_sent = false;
}
$account->save();
}
public function onMoneySubtracted(MoneySubtracted $event)
{
$account = Account::uuid($event->accountUuid);
$account->balance -= $event->amount;
$account->save();
}
public function onAccountDeleted(AccountDeleted $event)
{
$account = Account::uuid($event->accountUuid);
$account->delete();
}
}
A projector is a class that receives events and transforms it to another format. I'm not saying you should do this, but in theory you could for instance create a projector for every view in your application. In our Larabank app we use the MoneyAdded
and MoneySubtracted
events and use a projector to transform these to transaction_counts
table that hold the amount of transactions on each account.
final class TransactionCountProjector implements QueuedProjector
{
use ProjectsEvents;
protected $handlesEvents = [
MoneyAdded::class => 'onMoneyAdded',
MoneySubtracted::class => 'onMoneySubtracted',
];
public function onMoneyAdded(MoneyAdded $event)
{
$account = Account::uuid($event->accountUuid);
TransactionCount::record($account);
}
public function onMoneySubtracted(MoneySubtracted $event)
{
$account = Account::uuid($event->accountUuid);
TransactionCount::record($account);
}
}
We can use those transaction counts directly to drive a view.
class TransactionsController extends Controller
{
public function index()
{
$transactionCounts = TransactionCount::orderByDesc('count')->get();
return view('transactions.index', compact('transactionCounts'));
}
}
In a traditional application you probably would have written an expensive query to count and groups transaction per account. Using a projector you can just transform the events to a format that is easy to consume by your views.
If you want to know more about projectors and how to use them, head over to the using-projectors
section in the docs.
Using aggregates to make decisions based on the past
Now that you know what projections are, let's take it one step further with aggregates. In the previous examples whenever we wanted to fire off an event, we simply did so. When using aggregates, our main code is not going to fire events anymore. Instead, an aggregate will do that. An aggregate is a class that helps you to make decisions based on events that happened in the past.
Before firing off an event, an aggregate will first check if it is allowed to record that particular event. Using our Larabank example again, imagine you have to implement the rule that an account's balance is not allowed to go below -$5000. When trying to subtract money for a particular account, the aggregate will first loop through all previous events of that account and calculate the current balance. If the balance minus the amount we subtract is not less than -$5000, it will record that MoneySubtracted
event. After that, the MoneySubtracted
event will be passed to all projectors and reactors.
Let's go through this step by step.
Step 1: our app wants to subtract $1000. We create a new aggregate root instance and will feed it all events. There are no events yet to retrieve yet in this pass. The aggregate will conclude that it's allowed to subtract $1000 and will record that Subtract
event. This recording is just in memory, and nothing will be written to the DB yet.
Step 2: We are going to persist the aggregate. When persisting an aggregate, all of the newly recorded events that aggregate has will be written in the database. Also, if you have projectors set up, they will receive the newly persisted events as well.
Step 3: Let's hit that account limit and try to subtract $4800 now. First, the aggregate will be reconstituted from all previous events. Because it gets the earlier events in can calculate the current balance in memory (which is of course -$1000). The aggregate root can conclude that if we were to subtract $4800 we would cross our limit of -$5000. So it is not going to record that event. Instead, we could record that fact the account limit was hit.
Step 4: The aggregate gets persisted, and the account limit hit event gets written into the database.
So now we've protected our account from going below -$5000. Let's take it one step further and send our customer a loan proposal mail when he or she hits the account limit three times in a row. Using an aggregate this is easy!
Step 5: Let's again try to subtract a lot of money to hit that account limit of -$5000. We hit our account limit the second time.
Step 6: This time it gets interesting. We are going to try to subtract money and will hit our limit for the third time. Our aggregate gets reconstituted from all events. Those events get fed to the aggregate one by one. The aggregate in memory holds a counter of how many limit hit events it receives. That counter is now on 2. Because the amount we subtract will take us over the account limit, the aggregate will not record a subtract event, but a new limit hit event. It will update the limit hit counter from 2 to 3. Because the counter is now at 3. It can also record a new event called loan proposed. When storing the aggregate, the new events will get persisted in the database. All projectors and reactor will get called with these events. The LoanProposalReactor
hears that LoanProposed
event and send the mail.
All of the above is a lot to wrap your mind around. To help you understand this better, here's our Larabank app again, but this time built using aggregates. In the controller, you see that we don't fire events, but we are using an aggregate. Inside the aggregate we are going to record events that will get written to the database as soon as we persist the aggregate.
Whenever we retrieve an aggregate, all of the previously stored events will be fed to the aggregate one by one to it's apply*
methods. We can use those apply methods to recalculate things like the balance, or the times the account limit was hit as instance variables. When we want to try to subtract money we can use those instances variables to decide whether we are going to record the MoneySubtracted
event or record other events.
In summary, aggregates are used to make decisions based on past events.
Let's build an aggregate together!
When first learning about aggregates, I took a while to wrap my head around the concept. To help you understand event modeling using aggregates let's implement a small piece of the Larabank example app together.
We are going to add methods to record the AccountCreated
, MoneyAdded
and the MoneySubtracted
events.
Creating an aggregate
Using our package the easiest way to create an aggregate root would be to use the make:aggregate
command:
php artisan make:aggregate AccountAggregate
This will create a class like this:
namespace App\Aggregates;
use Spatie\EventProjector\AggregateRoot;
final class AccountAggregate extends AggregateRoot
{
}
Recording events
Remember, an aggregate is just a plain PHP class. You can add any methods or variables you need on the aggregate.
First, let's add a createAccount
methods to our aggregate that will record the AccountCreated
event.
namespace App\Aggregates;
use Spatie\EventProjector\AggregateRoot;
final class AccountAggregate extends AggregateRoot
{
public function createAccount(string $name, string $userId)
{
$this->recordThat(new AccountCreated($name, $userId));
}
public function addMoney(int $amount)
{
$this->recordThat(new MoneyAdded($amount));
}
public function subtractAmount(int $amount)
{
$this->recordThat(new MoneySubtracted($amount));
}
}
The recordThat
function will not persist the events to the database. It will simply hold them in memory. The events will get written to the database when the aggregate itself is persisted.
There are two things to notice. First, the method name is written in the present tense, not the past tense. We're trying to do something, and for the rest of our application is hasn't happened yet until the actual AccountCreated
is saved. This will only happen when the AccountAggregate
gets persisted.
The second thing to note is that nor the method and the event contain an uuid. The aggregate itself is aware of the uuid to use because it is passed to the retrieve method (AccountAggregate::retrieve($uuid)
, we'll get to this in a bit). When persisting the aggregateroot, it will save the recorded events along with the uuid.
With this in place you can use the aggregate.
AccountAggregate::retrieve($uuid)
->createAccount('my account', auth()->user()->id)
->persist();
AccountAggregate::retrieve($uuid)
->addMoney(123)
->persist();
AccountAggregate::retrieve($uuid)
->subtractMoney(456)
->persist();
When persisting an aggregate all newly recorded events inside aggregate root will be saved to the database. The newly recorded events will also get passed to all projectors and reactors that listen for them.
In our demo app we retrieve and persist the aggregate in the AccountsController
. The package has no opinion on where you should interact with aggregates. Do whatever you wish.
Implementing our first business rule
Let's now implement the rule that an account cannot go below -$5000. Here's the thing to keep in mind: when retrieving an aggregate all events for the given uuid will be retrieved and will be passed to methods named apply<className>
on the aggregate.
So for our aggregate to receive all past MoneyAdded
and MoneySubtracted
events we need to add applyMoneySubtracted
andapplyMoneySubtracted
methods to our aggregate. Because those events are all fed to the same instance of the aggregate, we can simply add an instance variable to hold the calculated balance.
// in our aggregate
private $balance = 0;
//...
public function applyMoneyAdded(MoneyAdded $event)
{
$this->balance += $event->amount;
}
public function applyMoneySubtracted(MoneySubtracted $event)
{
$this->balance -= $event->amount;
}
Now that we have the balance of the account in memory, we can add a simple check to subtractAmount
to prevents an event from being recorded.
public function subtractAmount(int $amount)
{
if (! $this->hasSufficientFundsToSubtractAmount($amount) {
throw CouldNotSubtractMoney::notEnoughFunds($amount);
}
$this->recordThat(new MoneySubtracted($amount));
}
private function hasSufficientFundsToSubtractAmount(int $amount): bool
{
return $this->balance - $amount >= $this->accountLimit;
}
Implementing another business rule
We can take this one step further. You could also record the event that the account limit was hit.
public function subtractAmount(int $amount)
{
if (! $this->hasSufficientFundsToSubtractAmount($amount) {
$this->recordThat(new AccountLimitHit($amount));
// persist this so the recorded event gets persisted
$this->persist();
throw CouldNotSubtractMoney::notEnoughFunds($amount);
}
$this->recordThat(new MoneySubtracted($amount));
}
Let's now add a new business rule. Whenever somebody hits the limit three times a loan proposal should be sent. We can implement that as such.
private $accountLimitHitCount = 0;
// we need to add this method to count the amount of this the limit was hit
public function applyAccountLimitHit()
{
$this->accountLimitHitCount++;
}
public function subtractAmount(int $amount)
{
if (! $this->hasSufficientFundsToSubtractAmount($amount) {
$this->recordThat(new AccountLimitHit($amount));
if ($this->accountLimitHitCount === 3) {
$this->recordThat(new LoanProposed());
}
// persist the aggregate so the recorded events gets persisted
$this->persist();
throw CouldNotSubtractMoney::notEnoughFunds($amount);
}
$this->recordThat(new MoneySubtracted($amount));
}
When the limit is hit three times, we record another event LoanProposed
. We could set up a reactor that listens for that event and sends the actual mail.
If you want to toy around with this example, clone the Larabank with aggregates example.
Closing thoughts
Congrats! You've almost reached the end of this post and should now have a basic understanding of what event sourcing is and how you can use aggregates to model a process. Laravel Event projector can help you set up aggregates, projectors and reactors. To learn more about the package, check out the extensive documentation.
When using event sourcing writes are expensive. Your application has a little bit more work than usual to write something. It will not simply update one record. When using aggregates, events will be replayed in memory, an events needs to written, projectors will need to get called. But the benefits are quite big. Reads are easy because you can just tranform the data to something that is easy to consume. Aggregates make it easy to make decisions based on the past.
Event sourcing might be a good choice for your project if:
- your app needs to make decisions based on the past
- your app has auditing requirments: the reason why your app is in a certain state is equally as important as the state itself
- you foresee that there will be a reporting need in the future, but you don't know yet which data you need to collect for those reports
Check out these resources to learn more about event sourcing:
- Event Sourcing made simple, a blogpost by Philippe Creux of Kickstarter. Fun fact: reading that blogpost kickstarted the work for our package.
- Dealing with change in event sourced applications, a talk by Michiel Rook
- Event Sourcing: What it is and why it's awesome, a blogpost by Barry O Sullivan
Laravel event projector is probably the easiest way to get started with event sourcing in a Laravel app. But there are some alternatives written in PHP:
- EventSauce is a pragmatic, framework agnostic event sourcing package built by FlySystem creator Frank De Jonge.
- EventSauce Laravel bindings is a package by our team that makes it easy to use EventSauce in a Laravel app.
- Prooph is a very feature rich suite of packages.
Here are some links on how we implemented some of the features in laravel-event-projector:
- Implementing event sourcing: improving the developer experience
- Implementing event sourcing: aggregates
Finally, be sure to check out all the open source packages my team has released previously. I'm sure there will be something there that could be of use in your next project.
A big thank you to my colleague Willem for the illustrations in this blogpost.
What are your thoughts on "Laravel event projector v2 has been released"?