laravel-state-machines
Installation
SKILL.md
Laravel State Machines
A status earns a state machine when its transitions have rules (only some moves are allowed) or side effects (timestamps, events, other writes). It is a state column cast to a spatie/laravel-model-states base state: the base state declares every allowed move, each move with work attached has its own transition class, and the model is the only thing that starts a transition. A status that is a plain value, set freely with nothing reacting, is a backed enum instead (see laravel-enums). An event-sourced model doesn't use model-states: its aggregate is the state machine (laravel-event-sourcing). Plain models in the same app still do.
Rules
- Transitions are declared on the base state.
#[AllowTransition(from, to, transition)]and#[DefaultState]on the abstract{Model}State. That attribute list is the whole graph; nothing else decides which moves exist. - States are
{Model}{State}beside the base state.OrderPaidinapp/States/Order/. Spatie discovers states by scanning the base state's directory, so they can't live elsewhere. - Every state sets
public static string $name. The column storespaid, not the class name, so renaming or moving a class never breaks stored rows. - Every state lists the transitions into and out of it in a
@seedocblock. Otherwise they're named only in the base state's attributes; the docblock takes a reader from a state straight to that code. - One transition class per move, named
{From}To{To}.PendingToPaidinapp/States/{Model}/Transitions/. Two sources into one target are two classes, never oneTransitionToCancelledcovering both: each move's side effects stay readable on their own. - A transition takes the model in its constructor and
handle()returns it. Spatie builds the class with the model first, callshandle(), and reads the state off the model it returns; returning a state breaks the transition.handle()assigns the new state, writes any fields the move owns, saves, dispatches, and returns the model. - A transition others react to dispatches an event. Named
{Model}{State}inapp/Events/, imported with an alias (as OrderPaidEvent) because it shares the state's short name. The transition is the only place it fires. - The model is the only caller of
transitionTo(). Each reachable state getsupdateTo{State}(): self|Model, returning whattransitionTo()returns (the package types it asModel). Actions call$order->updateToPaid(); nothing else touchestransitionTo(). - State checks are
is{State}()on the model.return $this->state->equals(OrderPaid::class);. Callers ask$order->isPaid(), never compare classes or strings. - Behaviour that varies by state is a capability on the state. Declare
abstract public function is{Capability}(): boolon the base state, implement it in every state, and delegate from the model:isEditable()returns$this->state->isEditable(). A new state then can't forget to answer, and the model never holds a list of states. - Guards live in the action and throw a domain exception.
throw_unless($order->state->canTransitionTo(OrderCancelled::class), OrderException::cannotCancel())in the action'sguard(), before any work. laravel-actions owns guards. - Not
final. States and transitions are plain classes.