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. OrderPaid in app/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 stores paid, 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 @see docblock. 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}. PendingToPaid in app/States/{Model}/Transitions/. Two sources into one target are two classes, never one TransitionToCancelled covering 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, calls handle(), 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} in app/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 gets updateTo{State}(): self|Model, returning what transitionTo() returns (the package types it as Model). Actions call $order->updateToPaid(); nothing else touches transitionTo().
  • 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}(): bool on 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's guard(), before any work. laravel-actions owns guards.
  • Not final. States and transitions are plain classes.
Installs
48
GitHub Stars
15
First Seen
Mar 27, 2026