Verbs
repository·main·Indexed 19 days ago
https://github.com/hirethunk/verbsA PHP package for implementing event sourcing designed to reduce boilerplate and simplify the mental model for developers. Verbs focuses on a decoupled event-state model, utilizing a specific lifecycle (Firing, Fired, and Committed) to manage state updates and side effects. It provides attributes like #[StateId], #[AppliesToState], and #[Once] to link events to states and control execution during event replays.
What's inside verbs
- Verbs is an event sourcing package for PHP developers designed to reduce boilerplate and jargon typically associated with event sourcing. The core philosophy encourages developers to "think in Verbs... not nouns," focusing on actions and events rather than just state changes.
What are States in Verbs?
mainStates are simple PHP objects that hold data which is mutated over time by events. They act as an in-memory accumulation of everything that has happened via events up to the current point. While models/databases represent the current snapshot of data, States represent the history and accumulation of the steps (events) taken to get there.
Key characteristics:
- In-memory: States are loaded once and kept in memory during the request, allowing real-time updates without constant database overhead.
- Lean: State files should focus on tracking properties, while logic should be offloaded to events.
- Pairing: A good rule of thumb is to pair States with your models (e.g.,
FooStatefor aFoomodel).
Implement the "Committed" phase hook
mainThe Committed phase occurs after the event has been successfully stored in the database. This is the safe zone for triggering side effects.
handle(): Used to perform actions based on the event, such as writing to a database (often referred to as a "projection").
Implement the "Firing" phase hooks
mainThe Firing phase occurs only when an event is first fired. It is used for setup, security, and integrity checks.
__construct(): Called exactly once. You must ensure the event has all necessary data by the time the constructor finishes, as no further data retrieval should occur after this point.authorize(): Used to verify if the current user has permission to fire the event (similar to Laravel form requests).validate*(): Any method starting withvalidateis a validation hook. These run against the states the event is firing on. Use$this->assert()within these methods to enforce business rules.
class UserJoinedTeam { public function validateUser(UserState $user) { $this->assert($user->can_join_teams, 'This user must upgrade before joining a team.'); } public function validateTeam(TeamState $team) { $this->assert($team->seats_available > 0, 'This team does not have any more seats available.'); } }Implement projections via event handle methods
mainWhile dedicated Projectors can be registered, the idiomatic way to perform projections in Verbs is to use the
handlemethod of an event. An event can project data into a model by updating specific fields.Example pattern: An
AccountWasDeactivatedevent might project acancelled_attimestamp onto theAccountmodel within itshandlemethod.How Events and States work together
mainVerbs uses a state-first approach where events interact with state objects through attributes and lifecycle methods:
- Linking State to Event: Use the
#[StateId(StateClass::class)]attribute on a property in your Event class to tell Verbs which State to look up using that property's value. validate(State $state): This method is called before the event fires. If it returnstrue, the event proceeds. If it returnsfalseor throws an exception, the event is blocked.apply(State $state): This method is called to mutate the state object when the event successfully fires.handle(): This method is used for side effects, such as updating Eloquent models in the database. It is called when the event is committed at the end of the request.
class CustomerBeganTrial extends Event { #[StateId(CustomerState::class)] public int $customer_id; public function validate(CustomerState $state) { $this->assert( $state->trial_started_at === null || $state->trial_started_at->diffInDays() > 365, 'This user has started a trial within the last year.' ); } public function apply(CustomerState $state) { $state->trial_started_at = now(); } public function handle() { Subscription::create([ 'customer_id' => $this->customer_id, 'expires_at' => now()->addDays(30), ]); } }- Linking State to Event: Use the
Understand the Events -> States -> Models lifecycle
mainIn Verbs, when using event sourcing, follow a strict hierarchy: Events influence States first, and Models last.
- States: Part of the event system. They are the first place event data is applied. States exist in memory and allow for complex business logic and calculations without immediate database overhead.
- Models: Primarily used for your application UI and persistence. They are updated last in the lifecycle.
To maintain a rebuildable system, it should always be possible to delete all models and rebuild them entirely by replaying your events against the states. The
handle()method is the final step in the event lifecycle where model modifications occur.How state hydration and dehydration work
mainVerbs uses state snapshots to manage the lifecycle of states in memory, optimizing performance by avoiding constant event re-processing.
Hydration
When you call
load()on a State:- If a snapshot exists: The state is hydrated by loading and deserializing the snapshot data from the database.
- If no snapshot exists: The system reconstructs the state from scratch by applying all relevant events stored in the event store.
Once hydrated, the state object is cached in memory. Subsequent access does not require database fetching unless the application restarts or the state is cleared from memory.
Dehydration
When
Verbs::commit()is called:- The event queue is processed.
- All affected state values are serialized.
- The serialized data is written to the
VerbSnapshottable in the database.
Commit Events to the database
mainWhen you call
fire(), events are pushed to an in-memory queue (similar to staging changes in git). They are eventually saved to the database in a singleinsertoperation.Verbs automatically calls
Verbs::commit()at the end of every request, console command, and queued job.Manual Committing:
- In tests, you often need to call
Verbs::commit()manually. - Inside database transactions, call
Verbs::commit()before the transaction commits to ensure the events are included in that transaction. - To fire an event and immediately persist it and receive its return value, use
Event::commit()instead offire().
// Committing within a transaction DB::transaction(function() { CustomerRegistered::fire(...); CustomerBeganTrial::fire(...); Verbs::commit(); }); // Firing and immediately getting the result $subscription = CustomerBeganTrial::commit(customer_id: Auth::id());- In tests, you often need to call
How Events and States are serialized
mainWhen storing Events and States in the database, Verbs uses the Symfony Serializer to convert them into JSON.
Only public properties on your objects are serialized. The process utilizes a set of Normalizers to transform the object data into a serializable format.
Naming Events
mainFollow the
WhoWhatformat (Noun + Verb) and always use the past tense, as events represent things that have already happened.Examples:
OrderCancelledCarLockedHolyGrailFound
Avoid mixing Models and States to prevent replay errors
mainDo not store references to Eloquent models (like model IDs) inside your events or states. Doing so can cause issues during event replaying; for example, if a model is recreated during a replay, it may receive a different auto-incremented ID, causing subsequent events to reference the wrong record.
Best Practice: Use Snowflakes or ULIDs across your entire application to mitigate ID mismatch issues, but ideally, keep models and event data decoupled.
By default, Verbs will throw an exception if you attempt to store a reference to a model inside an event or state.