States
States in Verbs are simple PHP objects containing data which is mutated over time by events. If that doesn't immediately give you a strong sense of what a state is, or why you would want one, you're not alone.
A Mental Model
Over time, you'll find your own analogue to improve your mental model of what a state does. This helps you understand when you need a state, and which events it needs to care about.
Here are some to start:
Stairs
Events are like steps on a flight of stairs. The entire grouping of stairs is the state, which accumulates and holds every step; the database/models will reflect where we are now that we've traversed the stairs.
Books
Events are like pages in a book, which add to the story; the state is like the spine--it holds the book together and contains the whole story up to now; the database/models are where we are in the story now that those pages have happened.
Generating a State
To generate a state, use the built-in artisan command:
1php artisan verbs:state GameState
When you create your first state, it will generate in a fresh app/States
directory.
A brand new state file will look like this:
1namespace App\States;2 3use Thunk\Verbs\State;4 5class ExampleState extends State6{7 // It ain't my birthday but I got my name on the cake - Lil Wayne8}
States and Events
Like our examples suggest, we use states for tracking changes across our events.
Thunk state files tend to be lean, focusing only on tracking properties and offloading most logic to the events themselves.
Applying Event data to your State
Use the apply()
event hook with your state to update any data you'd like the state
to track:
1// CountIncremented.php 2class CountIncremented extends Event 3{ 4 #[StateId(CountState::class)] 5 public int $example_id; 6 7 public function apply(CountState $state) 8 { 9 $state->event_count++;10 }11}12 13// CountState.php14class CountState extends State15{16 public $event_count = 0;17}18 19// test or other file20$id = snowflake_id();21 22CountIncremented::fire(example_id: $id);23Verbs::commit();24CountState::load($id)->event_count // = 1
If you have multiple states that need to be updated in one event, you can load both in the apply()
hook, or even write
separate, descriptive apply methods:
1public function applyToGameState(GameState $state) {}2 3public function applyToPlayerState(PlayerState $state) {}
On fire()
, Verbs will find and call all relevant state and event
methods prefixed with "apply".
Validating Event data using your State
It's possible to use your state to determine whether or not you want to fire your event in the first place.
We've added a validate()
hook for these instances. You can use validate()
to check against properties in the state;
if it returns false, the event will not fire.
You can use the built-in assert()
method in your validate()
check
1public function validate()2{3 $this->assert(4 $game->started, // if this has not happened5 'Game must be started before a player can join.' // then display this error message6 )7}
You can now see how we use the state to hold a record of event data--how we can apply()
event data to a particular
state, and how we can validate()
whether the event should be fired by referencing that same state data.
These and other hooks that helps us maximize our events and states are located
in event lifecycle.
Loading a State
All state instances are singletons, scoped to an id. i.e. say we had a Card Game app--if we apply
a CardDiscarded
event, we make sure only the CardState
state with its globablly unique card_id
is affected.
To retrieve the State, simply call load:
1CardState::load($card_id);
The state is loaded once and then kept in memory. Even as you apply()
events, it's the same, in-memory copy that's
being updated, which allows for real-time updates to the state without additional database overhead.
You can also use loadOrFail()
to trigger a StateNotFoundException
that will result in a 404
HTTP response if not
caught.
Using States in Routes
States implement Laravel’s UrlRoutable
interface, which means you can route to them in the exact same way you would
do route-model binding:
1Route::get('/users/{user_state}', function(UserState $user_state) {2 // $user_state is automatically loaded for you!3});
Singleton States
You may want a state that only needs one iteration across the entire application--this is called a singleton state. Singleton states require no id, since there is no need to differentiate among state instances.
In our events that apply to a singleton state, we simply need to use the
AppliesToSingletonState
attribute.
1#[AppliesToSingletonState(CountState::class)]2class IncrementCount extends Event3{4 public function apply(CountState $state)5 {6 $state->count++;7 }8}
This event uses AppliesToSingletonState
to tell Verbs that it should always be applied to a single CountState
across
the entire application (as opposed to having different counts for different situations).
Loading the singleton state
Since singleton's require no IDs, simply call the singleton()
method.
1YourState::singleton();
State Collections
Your events may sometimes need to affect multiple states.
Verbs supports State Collections out of the box, with several convenience methods:
1$event_with_single_state->state(); // State2$event_with_multiple_states->states(); // StateCollection
alias(?string $alias, State $state)
Allows you to set a shorthand name for any of your states.
1$collection->alias('foo', $state_1);
You can also set state aliases by setting them in the optional params of some of
our attributes: any #[AppliesTo]
attribute, and #[StateId]
.
get($key, $default = null)
Like the get()
collection method, but also preserves any
aliases. Returns a state.
1$collection->get(0); // returns the first state in the collection2$collection->get('foo'); // returns the state with the alias
ofType(string $state_type)
Returns a state collection with only the state items of the given type.
1$collection->ofType(FooState::class);
firstOfType()
Returns the first()
state item with the given type.
1$collection->firstOfType(FooState::class);
withId(Id $id)
(Id
is a stand-in for Bits|UuidInterface|AbstractUid|int|string
)
Returns the collection with only the state items with the given id.
1$collection->withId(1);
filter(?callable $callback = null)
Like the filter()
collection method, but also preserves any aliases. Returns a state collection.
1$activeStates = $stateCollection->filter(function ($state) {2 return $state->isActive;3});
What should be a State?
We find it a helpful rule of thumb to pair your states to your models. States are there to manage event data in memory, which frees up your models to better serve your frontfacing UI needs. Once you've converted to Unique IDs, you can use your state instance's id to correspond directly to a model instance.
1class FooCreated 2{ 3 #[StateId(FooState::class)] 4 public int $foo_id; 5 6 // etc 7 8 public function handle() 9 {10 Foo::create(11 snowflake: $this->foo_id12 );13 }14}
That said: if you ever find yourself storing complex, nested, multi-faceted data in arrays, collections, or objects on your state, you probably need another state. Particularly if the data in those collections, arrays, or objects is ever going to change.
Read more about the role states play in State-first development.