spatie/laravel-model-states
Add state-machine behaviour to Eloquent models: each state is its own class, with serialization handled automatically and state transitions made easy.
Install and wire it up
composer require spatie/laravel-model-states
php artisan vendor:publish --provider="Spatie\ModelStates\ModelStatesServiceProvider" --tag="model-states-config"
The published config has one knob — default_transition — which points at Spatie\ModelStates\DefaultTransition. You'll rarely touch it; it exists so you can swap the global transition behaviour (e.g. wrap persistence in your own conventions) without editing every state.
Three artifacts per state machine
Spatie\ModelStates\State, holding the shared contract (labels, colors, allowed actions) and the config() method that declares the graph.HasStates (or the HasStatesContract interface added in 2.11) plus a cast on the state column.class Payment extends Model
{
use HasStates;
protected $casts = ['state' => PaymentState::class];
}
abstract class PaymentState extends State
{
abstract public function color(): string;
public static function config(): StateConfig
{
return parent::config()
->default(Pending::class)
->allowTransition(Pending::class, Paid::class)
->allowTransition(Pending::class, Failed::class);
}
}
Since v2, configuration lives on the state class, not the model, and states know which field they belong to — so you call $payment->state->canTransitionTo(Paid::class) rather than passing a field name around.
Your first use case. The sweet spot is a lifecycle column you currently handle with a string/enum plus switch statements: payments, orders, subscriptions, imports, KYC reviews. You get (a) a typed object instead of a magic string, (b) a single declared graph of legal moves, (c) behaviour methods (color(), label(), isRefundable()) that live next to the state instead of in the model, and (d) a StateChanged event you can hang audit logging off.
Where to look first, in order: the docs at spatie.be/docs/laravel-model-states/v2 (start with "Configuring states" and "Transitions"), then StateConfig + DefaultTransition in src/, then the StateChanged event. Read the 2.0.0 changelog entry before reading any older blog post — most tutorials online are v1 and show APIs (isOneOf, State::find, per-model config) that no longer exist.
Day one example:
$payment->state->transitionTo(Paid::class);
echo $payment->state->color(); // 'green'
$payment->state->canTransitionTo(Failed::class); // bool
Guarded transitions in a custom transition class. allowTransition() only declares the graph is possible; business rules belong in a dedicated transition class, registered as the third argument:
->allowTransition(Pending::class, Paid::class, PayPendingPayment::class)
class PayPendingPayment extends DefaultTransition
{
public function handle(): State
{
if ($this->model->amount->isZero()) {
throw new InvalidPayment();
}
// ... side effects (ledger entry, mail) ...
return parent::handle(); // or assign + save yourself
}
}
canTransition() on the transition class is honoured by canTransitionTo() (2.1.1), so your UI "is this button legal?" check and your runtime guard stay in sync. Custom transition constructors may take extra arguments passed through transitionTo(Paid::class, $arg) (2.12.0).
Multi-step flows via dedicated methods on the state. Rather than letting controllers call transitionTo with arbitrary targets, expose intent on the abstract state or the model:
$subscription->state->cancel(); // implemented on Active only; throws elsewhere
This keeps the graph in one place and makes it obvious in code review which transitions exist.
Multiple state fields per model. $casts can hold several state columns (state, fulfilment_state, kyc_state), each with its own abstract state class and its own graph. State::getModel() and State::getField() (2.0.2) let shared base logic know where it is.
Validation. Never accept a raw state string from a form. Use the bundled rule:
'state' => ['required', new \Spatie\ModelStates\Validation\StateValidation(PaymentState::class)],
The rule (recently moved to Laravel's ValidationRule interface) rejects anything not in the hierarchy — which matters because assigning an arbitrary class via the cast is how people accidentally "escape" the graph.
Events and audit trails. Every transition fires StateChanged ($model, $oldState, $newState, and since 2.12.0 $field). The idiomatic pattern is a listener that writes to a state_transitions history table — the package deliberately keeps no history itself. Keep listeners idempotent and free of further transitions; if a listener must transition again, do it queued and outside the original transaction.
Serialisation to APIs / Inertia / Blade. State objects are not the right thing to put in a payload. Expose $model->state->getMorphClass() (the stored value) plus a human label from an abstract label() method, e.g. in an API resource:
'state' => ['value' => $this->state->getMorphClass(), 'label' => $this->state->label()],
This also gives the front end a stable key to branch on without leaking PHP class names.
Factories, seeders, tests. Because the cast resolves class-strings through resolveStateClass, factories can do Payment::factory()->create(['state' => Paid::class]) — but check this against the version you install, and prefer a paid() factory state that sets the state explicitly. In tests, assert on $model->state->equals(Paid::class) rather than instanceof, and use Event::fake([StateChanged::class]) when you only care about the transition itself.
Migrating off a string/enum column. Add the state column (string, indexed), write a backfill that maps old values to morph values, add the cast, then remove reads of the old column. Do it in that order — the cast will blow up on unrecognised values, so backfill before deploying the model change.
States outside app/. Discovery scans conventional locations; if your states live in a module/domain namespace, register the extra directories or classes explicitly (see 2.4.0 and 2.11.3 in the changelog) instead of fighting the autoloader.
new Payment() plus manual attribute manipulation won't necessarily initialise the state. Backfill in a migration and keep the column NOT NULL where you can.Active → Active throws unless you explicitly allowTransition(Active::class, Active::class). That's usually correct (idempotent endpoints should short-circuit with equals() first), but it surprises people in bulk jobs.DefaultTransition writes the attribute and saves the model; a custom transition that overrides handle() and forgets to save silently does nothing after the request ends. Conversely, if you're inside DB::transaction, be aware the transition's own save happens inline, and the StateChanged event fires inside that transaction.Spatie\ModelStates\Exceptions\TransitionNotFound (and friends). Map these to HTTP 409 in your exception handler rather than letting them 500 — a stale UI tab triggering a double transition is a normal user event, not a bug.getMorphClass() if you want short keys ('paid'), and decide this before you ship, not after.where('state', 'App\States\Payment\Paid') (or the morph value from your own constant) rather than relying on the cast to coerce query values; don't assume where('state', Paid::class) is handled for you on older Laravel versions. Index the state column if you filter on it — this is a string column, not a lookup table.null, so $model->state->transitionTo(...) becomes a "call on null" error. Guard with a check or make the column non-nullable with a default.toArray()/json_encode() of a model containing a state is not automatically what you want; __toString support only landed in 2.14.2, and string casting isn't a serialisation strategy. Use explicit resources.$oldStateObject still represents the old state. Always re-read $model->state.casts()-method casting and PHP 8.5 had a trait-binding-order regression fixed in 2.14.0, and an aliased-default regression fixed in 2.14.1. Require ^2.14 if you're on PHP 8.5 / Laravel 12–13.canTransition()/custom transitions. If you need approvals, escalations, or scheduled transitions, layer that on top (queued jobs + history table) rather than stretching the config.$model->getOriginal('state') shows what was persisted vs. $model->state; transitionableStates() and transitionableStateInstances() (2.12.0) dump the whole legal set for a state — invaluable in a Tinker session when a button "doesn't work"; newer versions also expose count/boolean helpers for available transitions if you want to render a menu without a loop.How can I help you explore Laravel packages today?