Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Model States Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. An abstract state class extending Spatie\ModelStates\State, holding the shared contract (labels, colors, allowed actions) and the config() method that declares the graph.
  2. One concrete class per state.
  3. A model using 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

Implementation Patterns

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.

Gotchas and Tips

  • Defaults apply only on model creation (v2 breaking change). Existing rows get no default, and 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.
  • Same-state transitions fail unless allowed. Transitioning 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.
  • Persistence lives in the transition. The bundled 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.
  • Catch the right exceptions. Illegal moves throw 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.
  • The stored value is the morph class — by default the fully-qualified class name. Renaming or moving a state class breaks every existing row. Override getMorphClass() if you want short keys ('paid'), and decide this before you ship, not after.
  • Querying: write 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.
  • Nullable columns return 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.
  • Don't hold stale state objects. State instances are resolved from the attribute; after a transition, $oldStateObject still represents the old state. Always re-read $model->state.
  • Version pinning matters for modern stacks. 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.
  • This is a state pattern, not a workflow engine. No parallel states, no timeouts, no transition history, no guards on arbitrary attributes beyond what you write in canTransition()/custom transitions. If you need approvals, escalations, or scheduled transitions, layer that on top (queued jobs + history table) rather than stretching the config.
  • Useful extension points when debugging: $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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator