Lead Software Engineer at Mumsnet .

Published on

Embracing The Tall Stack with Event Sourcing, an adventure into the unknown

TALL stack is growing in popularity in the Laravel community, and rightly so. The way in which you can write beautiful code split into reactive components without ever having to leave PHP is a no brainer. Recently the TALL stack preset was released, which on its own is fantastic - but I wanted to take it one step further.

A few days ago I read some absolutely fantstic articles by the people over at Spatie, specifically Freek Van Der Herten and Brent Roose - but I am sure more of the team were involved.

To summarise these two articles (one written by Brent)(one written by Freek), using a fully event sourced system will add complexities that can get difficult to manage very quickly - especially when not everything really needs to be event sourced. Using a fully stateful application has been the standard for a lot of the industry for many years, and is your typical go to system. Most people want some level of CRUD (create read update delete) behaviour on a set of resources, and the application will just be a database wrapper allowing for manipulation and aggregation of these resources. I would like to stress here, there is nothing wrong with this approach at all. In short terms, this article poses the question: Why can these approaches be mixed in the same system?

It sounds obvious right? Why has nobody asked this before? As soon as I read these articles it clicked with me, this is an obvious approach to the event sourcing problem while adding extra business intelligence to a traditional stateful application. So with that in mind I decided to combine the awesome power of the TALL stack with a simple Event Sourced application, so far it doesn't do much - simple user registration and password reset functionality that comes as standard in a Laravel application, but also in the tall stack preset. The purpose of this exercise was to see how well these approaches would work combined.

As with all Laravel projects, the first step is installing, I highly recommend using the Laravel Installer when creating a Laravel project - it gives a nice easy way to bootstrap the application with the auth scaffolding preloaded.

1laravel new project-name --auth

This will give you the default structure you need for any Laravel based project, from here we can start applying changes to the structure to align with the Spatie article - defining our application Contexts. For this application I have 1 context: a User context. Any behaviour to do with users at all will be stored within here. The architecture itself is still a work in progress, and I will most likely expand on this at some point soon.

The next step after installing is to follow the instructions over at the GitHub repo for the TALL stack preset. These are simple enough, just make sure you follow the setup instructions for with auth.

Our next step is to refactor the application to use these contexts, if you are using PHPStorm then this is relatively easy to do, right click the class name -> refactor -> move class. The first step is to refactor the User Model to sit under app/Context/User/Models/User.php and make a few recommended changes:

1namespace App\Context\User\Models;
3use Ramsey\Uuid\Uuid;
4use App\Context\User\Events\UserCreated;
5use Illuminate\Notifications\Notifiable;
6use Illuminate\Contracts\Auth\MustVerifyEmail;
7use Illuminate\Foundation\Auth\User as Authenticatable;
9class User extends Authenticatable
11 use Notifiable;
13 protected $guarded = [];
15 protected $hidden = [
16 'password', 'remember_token',
17 ];
19 protected $casts = [
20 'email_verified_at' => 'datetime',
21 ];
23 public static function createWithAttributes(array $attributes): User
24 {
25 $attributes['uuid'] = (string) Uuid::uuid4();
26 event(new UserCreated($attributes));
28 return static::uuid($attributes['uuid']);
29 }
31 public static function uuid(string $uuid): ?User
32 {
33 return static::where('uuid', $uuid)->first();
34 }

Now we have our User model updated in the right place, we need to make some changes in a few places:

  • app/config/auth.php make sure to change the providers model to the new model context
  • next we will have a new set of LiveWire components available under app/Http/Livewire/Auth which we will be working on next.

Once you have hooked up the auth correctly, feel free to test from this point to ensure it is working and all function ality works as it is. Our next step is to install the spatie/laravel-event-sourcing package - I would highly recommend have a thorough read through the documentation and watching the introductory videos, they explain things very well.

Once we have the Spatie package installed, we need to start using it! The first step is to create an Event that is going to be triggered and stored. So, let's create our UserCreated event in app/Context/User/Events/UserCreated.php it should look like this:

1namespace App\Context\User\Events;
3use Spatie\EventSourcing\ShouldBeStored;
5class UserCreated implements ShouldBeStored
7 /**
8 * @var string
9 */
10 public string $name;
12 /**
13 * @var string
14 */
15 public string $email;
17 /**
18 * @var string
19 */
20 public string $password;
22 /**
23 * User Created Constructor
24 *
25 * @param string $name
26 * @param string $email
27 * @param string $password
28 *
29 * @return void
30 */
31 public function __construct(
32 string $name,
33 string $email,
34 string $password
35 ) {
36 $this->name = $name;
37 $this->email = $email;
38 $this->password = $password;
39 }

What we are doing here is simple creating the event class with the attributes we would use to create a user normally, nothing out of the ordinary! So we have an event we can use, what we would typically do here is tie this into an Aggregate. So let us create an aggregate in app/Context/User/Aggregates/UserAggregate.php that should look something like the below:

1namespace App\Context\User\Aggregates;
3use Spatie\EventSourcing\AggregateRoot;
4use App\Context\User\Events\UserCreated;
6class UserAggregate extends AggregateRoot
8 public function createUser(
9 string $name,
10 string $email,
11 string $password
12 ) {
13 $this->recordThat(new UserCreated($name, $email, $password));
15 return $this;
16 }

What this Aggregate will do is record the fact that an event was triggered - the event sourcing part. For our user to actually be persisted into the database however we need to create a Projection - the part that will write data into a database. All we have at the moment is an event being fired and an aggregate that will store an event saying this event was triggered with the attatched properties.

The next part we need to do is not only create a projection, but also we are going to want to have reactors so that we can react when events happen. Let's start with our projection which wil be stored at app/Context/User/Projectors/UserProjector.php and look something like this:

1namespace App\Context\User\Projectors;
3use App\Context\User\Models\User;
4use App\Context\User\Events\UserCreated;
5use Spatie\EventSourcing\Projectors\Projector;
6use Spatie\EventSourcing\Projectors\ProjectsEvents;
8class UserProjector implements Projector
10 use ProjectsEvents;
12 /**
13 * Creates a new user
14 *
15 * @param UserCreated $event
16 * @param string $aggregateUuid
17 *
18 * @return void
19 */
20 public function onUserCreated(UserCreated $event, string $aggregateUuid)
21 {
22 User::create([
23 'uuid' => $aggregateUuid,
24 'name' => $event->name,
25 'email' => $event->email,
26 'password' => $event->password
27 ]);
28 }

What this Projector is doing as you can probably see, is using an Eloquent model writing our user into the database, using the attributes that we attached onto our UserCreated Event. Simple right? We ask our aggreate to send an event with some data, this data is then projected into the database while we store the fact that we just did something. So we have a user, we could stop here and implement the TALL stack element, but I would rather do that after we have set everything up.

Next, our reactor. Typically when a user is created on a system we want to send them an email welcoming them or ask them to confirm their email or anything like that. In this example we are going to ask them to verify their email using a reactor, this will be stored at app/Context/User/Reactors/UserCreatedReactor.php and look like the below:

1namespace App\Context\User\Reactors;
3use App\Context\User\Models\User;
4use App\Context\User\Events\UserCreated;
5use Spatie\EventSourcing\EventHandlers\EventHandler;
6use Spatie\EventSourcing\EventHandlers\HandlesEvents;
8class UserCreatedReactor implements EventHandler
10 use HandlesEvents;
12 public function onUserCreated(UserCreated $event)
13 {
14 $user = User::where('email', $event->email)->first();
15 $user->sendEmailVerificationNotification();
16 }

Using default Laravel behaviour we can now send an email notification over to the user that was just created asking them to verify their email. We have now come full circle! We will need to create a ServiceProvider in Laravel to tell the framework to listen and how to route these events and aggregates but that is relatively simple, you could either add this to your AppServiceProvider or create a specific service provider - I lean towards the latter:

1namespace App\Providers;
3use Illuminate\Support\ServiceProvider;
4use Spatie\EventSourcing\Projectionist;
5use App\Context\User\Projectors\UserProjector;
6use App\Context\User\Reactors\UserRegisteredReactor;
8class EventSourcingServiceProvider extends ServiceProvider
10 /**
11 * Register services.
12 *
13 * @return void
14 */
15 public function register()
16 {
17 //
18 }
20 /**
21 * Bootstrap services.
22 *
23 * @return void
24 */
25 public function boot()
26 {
27 Projectionist::addProjector(UserProjector::class);
28 Projectionist::addReactor(UserCreatedReactor::class);
29 }

Make sure you add this to the application config file at config/app.php under the providers array.

Now we have all of the main event sourcing aspects put together, it is time to hook up the LiveWire components to actually start triggering these events. Now this part is not perfect, and is only an example, but as a system scales this is where we may start seeing issues. What we do in our LiveWire component is ask our UserAggregate to trigger an event and persist the event however, if our system comes under heavy load this write may take awhile so our user will not be available to be logged in - one option here it to notify the user to verify their email before they can log in (probably a good idea), but what this demo does is simply trigger and log in because I am working locally so there is no load - these events and writes as fast!

So all of the livewire set up would have been done for you by installing and using the TALL preset, which is great, the next step to take is to change the default behaviour in this component found here app/Http/Livewire/Auth/Register.php and update the register method to the following code:

1namespace App\Http\Livewire\Auth;
3use Ramsey\Uuid\Uuid;
4use Livewire\Component;
5use Illuminate\Support\Str;
6use App\Context\User\Models\User;
7use Illuminate\Support\Facades\Auth;
8use Illuminate\Support\Facades\Hash;
9use App\Providers\RouteServiceProvider;
10use App\Context\User\Aggregates\UserAggregate;
12class Register extends Component
14 /* .. rest of the component lives here */
15 public function register()
16 {
17 $this->validate([
18 'name' => ['required'],
19 'email' => ['required', 'email', 'unique:users'],
20 'password' => ['required', 'min:8', 'same:passwordConfirmation'],
21 ]);
23 $uuid = Str::uuid()->toString();
25 UserAggregate::retrieve($uuid)
26 ->createUser(
27 $this->name,
28 $this->email,
29 Hash::make($this->password)
30 )->persist();
32 $user = User::uuid($uuid);
33 Auth::login($user, true);
34 redirect(route('home'));
35 }

So what we are doing here, when we ask the LiveWire component to register through our front end it will send a request to our back end using the native fetch() API, the library will take care of routing this request to the right place. Our first step of course is to validate the "request" as if it were a typical HTTP request (unfortunately no FormRequets here, which would be amazing), then we need to create a UUID for our aggregate, and we also assign this to our user (I am not sure on this part, if it is a good practice or not - I will let the community decide that one). Once we have our UUId we can tell our Aggregate to (using to UUID we just created) record the fact that we are trigger the UserCreated event we made earlier. We pass through all the data required, please note that we send over the hashed version of the password! The last thing we want to do is send over the unencrpyted password to be stored as plain text in out events - it is just as bad as storing a plain text password normally, you wuoldn't do it - so don't do it here! We then persist this aggregate to the database, so we have a rollback point, and an event to replay should we need to. We then fetch the user using their UUID, which is a static method you will remeber adding to your User model. We log this user in and redirect back to our home route.

It is that simple. Now I will be the first to say that this isn't perfect, it is mainly an experiment that I wanted to try after being inspired by a Spatie article and loving the TALL stack. Curiosity is a powerful tool, if you wonder if something should or could be done, try it! Experimentation is what makes technology moving forward. You never know something you try for fun could turn out to be a really good idea, never be scared to try things even if they may not work.

To expand on the above approach, my eventual aim is to have this working across multiple contexts: some stateful and others not so much. Beyond the code you see above, I also managed to get this approach working for password resets, so that we can have rollback points if a users password changes and they dispute it not being them, but also email verification - this last one was more for fun. The code I used for this example is currently in a GitHub repo should you want to have a look over that instead.

Thanks for reading! Why not drop me a tweet with your thoughts?