Models in Verbs

This is a first draft—some of the ideas/examples in here may change after initial feedback!

The question around using Models and IDs in Verbs comes up a lot on the Verbs Discord, so I thought I'd put together a summary of my current thoughts.

In Verbs, it's best to have a clear flow of data from events, to everywhere else. In an ideal world, your events are the single source of truth in your application (although this often can't be entirely the case because you may have data that predates adding Verbs to your project).

Best-case scenario

So let’s talk about the “ideal” scenario, where your models are all derived from events. Here's a highly simplified User model:

1Schema::create('users', function(Blueprint $table) {
2 $table->snowflakeId(); // Helper function in `glhd/bits`
3 $table->string('name');
4 $table->string('email')->unique();
5});

When a user registers, we fire an event:

1class UserRegistered extends Event
2{
3 public function __construct(
4 public string $name,
5 public string $email,
6 
7 // When a state ID is nullable, Verbs will assume that this is a "create"-style
8 // event, and auto-populate the value with a new Snowflake ID for us.
9 #[StateId(UserState::class)]
10 public ?int $user_id = null,
11 ) {}
12 
13 public function handle()
14 {
15 User::create([
16 // Note how we *explicitly* set the user's ID here. This is because
17 // the `user_id` value is globally-unique in our app, so we don't have
18 // to worry about things like collisions. If we ever need to replay this
19 // event, we'll just truncate the entire users table and recreate
20 // everything from scratch.
21 'id' => $this->user_id,
22 'name' => $this->name,
23 'email' => $this->email,
24 ]);
25 }
26}
1UserRegistered::fire(
2 name: $request->input('name'),
3 email: $request->input('email'),
4 // We don't need to pass `user_id` because Verbs handles that for us
5);

Later, if that user becomes an admin, we can just use the ID on the model, because we know that the ID is managed by our events (along with all the other data):

1class UserPromotedToAdmin extends Event
2{
3 public function __construct(
4 public int $user_id,
5 // Protected properties aren't stored by Verbs, so they can be useful
6 // for storing data for optimization purposes. Here, we'll allow optionally
7 // providing the User model so that we don't have to query it a second
8 // time if that's not necessary.
9 protected ?User $user = null,
10 ) {}
11 
12 public function handle()
13 {
14 // Load the user in case we don't have it (e.g. if we're replaying)
15 $this->user ??= User::find($this->user_id);
16 
17 $this->user->update(['role' => 'admin']);
18 }
19}
1class User extends Model
2{
3 // ...
4 
5 public function promoteToAdmin()
6 {
7 UserPromotedToAdmin::fire(
8 user_id: $this->getKey(),
9 user: $this, // This is optional--just an optimization
10 );
11 }
12}

A basic timeline

Because all the data (including the ID) comes from our events, we can safely use it across our app. Let's look at a basic timeline of events playing out in our app:

  1. A UserRegistered event fires with a snowflake ID of …27776 (snowflakes tend to be long)
  2. A second UserRegistered event fires with a snowflake ID of …24800
  3. A UserPromotedToAdmin event fires for user …27776
  4. We need to change our code in some way, and afterward we truncate users and replay
  5. Our first UserRegistered event replays with the same snowflake ID of …27776
  6. Our second UserRegistered event replays with the same snowflake ID of …24800
  7. Our UserPromotedToAdmin event replays for user …27776

As you can see, our IDs are all managed inside our events, so when we replay them, everything is re-created exactly as it was the first time. This is good!

Worst-case scenario

OK, so now let’s look at a similar approach that relies on auto-incrementing IDs (which are managed by our database, not our events).

1Schema::create('users', function(Blueprint $table) {
2 $table->id(); // Regular, auto-incrementing ID
3 $table->string('name');
4 $table->string('email')->unique();
5});

When a user registers, we fire a similar event, except we rely on the database to create an auto-incrementing ID for us:

1class UserRegistered extends Event
2{
3 public function __construct(
4 public string $name,
5 public string $email,
6 ) {}
7 
8 public function handle()
9 {
10 User::create([
11 'name' => $this->name,
12 'email' => $this->email,
13 ]);
14 }
15}

Then, in subsequent events, we rely on that auto-incrementing ID (this is the exact same code as above, but in this case, the ID is managed outside our events):

1class UserPromotedToAdmin extends Event
2{
3 public function __construct(
4 public int $user_id,
5 protected ?User $user = null,
6 ) {}
7 
8 public function handle()
9 {
10 $this->user ??= User::find($this->user_id);
11 $this->user->update(['role' => 'admin']);
12 }
13}

Revisiting our timeline

Let's look at the same timeline if we were to use auto-incrementing IDs:

  1. A UserRegistered event fires with an ID of 1
  2. A second UserRegistered event fires with an ID of 2
  3. A UserPromotedToAdmin event fires for user 1
  4. We need to change our code in some way, and afterward we truncate users and replay
  5. Our first UserRegistered event replays with an ID of 3
  6. Our second UserRegistered event replays with an ID of 4
  7. Our UserPromotedToAdmin event replays for user 1—but now there isn’t a user with the ID 1 so our app crashes

Because some data is managed in our events, and some (the IDs) are managed outside them, we run into a bug (this is especially true when replaying data). This is bad!

Compromise scenario

So what can you do if you have models that use auto-incrementing IDs that predate your events? The approach I like to take is “adopting” models into our event system and assigning them unique IDs in the process.

So, given the same users table above, we might add a new column:

1Schema::table('users', function(Blueprint $table) {
2 // You can (and probably should) still use snowflakes here, but I'm going
3 // to use UUIDs just to show that snowflakes aren’t an absolute requirement
4 // for Verbs. We're going to leave the value nullable for now, since in this
5 // example we're going to assume that there's some data that pre-dates events.
6 $table->uuid('universal_id')->nullable()->unique();
7});

For all our existing users, we can fire a one-time “adoption” event that brings that user into our event system:

1class LegacyUserImported extends Event
2{
3 public function __construct(
4 public int $legacy_user_id,
5 public string $universal_id,
6 public string $name,
7 public string $email,
8 ) {}
9 
10 public function handle()
11 {
12 // On first run, we're going to adopt the legacy user into our
13 // event system. On subsequent replays, we'll treat this
14 // event as a "create" event
15 Verbs::isReplaying()
16 ? $this->recreateLegacyUser()
17 : $this->adoptLegacyUser();
18 }
19 
20 protected function adoptLegacyUser()
21 {
22 User::find($this->legacy_user_id)
23 ->update(['universal_id' => $this->universal_id]);
24 }
25 
26 protected function recreateLegacyUser()
27 {
28 // Note that the user will be assigned a new `id` column value here,
29 // which will probably be an issue. You can either: a) set the `id`
30 // value here, too, which may require some other intervention on your
31 // part (like setting the table's auto-increment value to something very
32 // high so that no new users are accidentally assigned the ID before
33 // you finish replaying), or b) queue up a job to update related models
34 // with the new ID (which may cause issues if you replay multiple times
35 // unless you're careful)
36 $user = User::create([
37 'universal_id' => $this->universal_id,
38 'name' => $this->name,
39 'email' => $this->email,
40 ]);
41 }
42}
1User::whereNull('universal_id')->each(function(User $user) {
2 LegacyUserImported::fire(
3 legacy_user_id: $user->getKey(),
4 universal_id: Str::uuid(), // Create a new unique ID for them
5 name: $user->name,
6 email: $user->email,
7 );
8});

And when a new user registers after our event system is in place, we fire an event:

1class UserRegistered extends Event
2{
3 public function __construct(
4 public string $universal_id,
5 public string $name,
6 public string $email,
7 ) {}
8 
9 public function handle()
10 {
11 User::create([
12 'universal_id' => $this->universal_id,
13 'name' => $this->name,
14 'email' => $this->email,
15 ]);
16 }
17}
1UserRegistered::fire(
2 // We'll create a new UUID when we fire the event. This UUID will
3 // be the only ID that we care about inside our events. Our model
4 // will still have an auto-incrementing ID, but we won't rely on that
5 // anywhere that we're *writing* data.
6 universal_id: Str::uuid(),
7 name: $request->input('name'),
8 email: $request->input('email'),
9);

On subsequent events, we’ll exclusively use our universal_id value for queries, which means we’re back to relying entirely on event data:

1class UserPromotedToAdmin extends Event
2{
3 public function __construct(
4 public int $user_universal_id,
5 protected ?User $user = null,
6 ) {}
7 
8 public function handle()
9 {
10 $this->user ??= User::query()
11 ->where('universal_id', $this->user_universal_id) // We're using our new column, not `id`
12 ->sole();
13 
14 $this->user->update(['role' => 'admin']);
15 }
16}

The compromise timeline

Some of our data predates events, and some comes exclusively from them. What does that look like?

  1. We have an existing user with the ID 1
  2. We fire a LegacyUserImported event for user 1 assigning them a UUID 7c40fd6b…
  3. A UserRegistered event fires with an auto-incrementing ID of 2 and a UUID of 031c1b0e…
  4. A UserPromotedToAdmin event fires for user with universal ID 7c40fd6b…
  5. We need to change our code in some way, and afterward we truncate users and replay
  6. Our LegacyUserImported event replays and creates a user with universal ID 7c40fd6b…
  7. Our UserRegistered event replays with the same UUID 031c1b0e…
  8. Our UserPromotedToAdmin event replays for user 7c40fd6b…

In this case, even though the actual id column is still managed outside our events, that's OK because all writes rely on the universal_id column instead. That's good enough!

(It's worth noting that this approach can be useful in applications that need to use UUIDs or ULIDs for some reason, since those are generally worse primary keys than 64-bit integers for lookup. Your regular application code can still query/join by id in reads, keeping them fast, and you only have to use the universal_id index when doing writes.)

Or, just decide that some models are off-limits for replay

In reality, you may have a ton of data that predates adding Verbs to your app, and much of that data spans dozens of tables. A pragmatic approach, in that case, is to just accept that your existing data cannot be truncated and re-created, and only replay events that create new models.

In this scenario, you lose some of the flexibility but get a simpler implementation in exchange, which is often worth it.

In that case, our import event might look something like:

1class LegacyUserImported extends Event
2{
3 public function __construct(
4 public int $legacy_user_id,
5 public string $universal_id,
6 ) {}
7 
8 public function handle()
9 {
10 // Just assume the user is alway in the DB with this ID
11 User::find($this->legacy_user_id)
12 ->update(['universal_id' => $this->universal_id]);
13 }
14}