Models in Verbs
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 because17 // the `user_id` value is globally-unique in our app, so we don't have18 // to worry about things like collisions. If we ever need to replay this19 // event, we'll just truncate the entire users table and recreate20 // 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 us5);
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 optimization10 );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:
- A
UserRegistered
event fires with a snowflake ID of…27776
(snowflakes tend to be long) - A second
UserRegistered
event fires with a snowflake ID of…24800
- A
UserPromotedToAdmin
event fires for user…27776
-
We need to change our code in some way, and afterward we truncate
users
and replay - Our first
UserRegistered
event replays with the same snowflake ID of…27776
- Our second
UserRegistered
event replays with the same snowflake ID of…24800
- 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 ID3 $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:
- A
UserRegistered
event fires with an ID of1
- A second
UserRegistered
event fires with an ID of2
- A
UserPromotedToAdmin
event fires for user1
-
We need to change our code in some way, and afterward we truncate
users
and replay - Our first
UserRegistered
event replays with an ID of3
- Our second
UserRegistered
event replays with an ID of4
- Our
UserPromotedToAdmin
event replays for user1
—but now there isn’t a user with the ID1
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 going3 // to use UUIDs just to show that snowflakes aren’t an absolute requirement4 // for Verbs. We're going to leave the value nullable for now, since in this5 // 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 our13 // event system. On subsequent replays, we'll treat this14 // event as a "create" event15 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 your31 // part (like setting the table's auto-increment value to something very32 // high so that no new users are accidentally assigned the ID before33 // you finish replaying), or b) queue up a job to update related models34 // with the new ID (which may cause issues if you replay multiple times35 // 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 them5 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 will3 // be the only ID that we care about inside our events. Our model4 // will still have an auto-incrementing ID, but we won't rely on that5 // 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?
- We have an existing user with the ID
1
- We fire a
LegacyUserImported
event for user1
assigning them a UUID7c40fd6b…
- A
UserRegistered
event fires with an auto-incrementing ID of2
and a UUID of031c1b0e…
- A
UserPromotedToAdmin
event fires for user with universal ID7c40fd6b…
-
We need to change our code in some way, and afterward we truncate
users
and replay - Our
LegacyUserImported
event replays and creates a user with universal ID7c40fd6b…
- Our
UserRegistered
event replays with the same UUID031c1b0e…
- Our
UserPromotedToAdmin
event replays for user7c40fd6b…
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 ID11 User::find($this->legacy_user_id)12 ->update(['universal_id' => $this->universal_id]);13 }14}