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 State Machine Laravel Package

sebdesign/laravel-state-machine

A lightweight Laravel package for adding state machines to Eloquent models. Define states, transitions, and guards, then move records safely between states with validation, events, and clear exceptions. Integrates cleanly with Laravel and supports custom classes.

View on GitHub
Deep Wiki
Context7

Getting Started

sebdesign/laravel-state-machine is a thin Laravel wrapper around Symfony's Workflow component: you describe places and transitions in config (or code), and the package gives you a facade to ask "can this model move?" and "move it". Almost all the semantics you'll debug day-to-day are Symfony Workflow semantics, so keep the Symfony workflow docs open in a second tab.

Minimal setup

composer require sebdesign/laravel-state-machine
php artisan vendor:publish --tag=state-machine   # config/state-machine.php

Define a machine in config/state-machine.php:

return [
    'state_machines' => [
        'order' => [
            'type' => 'state_machine',      // or 'workflow' for many-places-at-once
            'marking_store' => [
                'type' => 'single_state',  // or 'method'
                'property' => 'status',    // the model attribute holding the place
            ],
            'supports' => [App\Models\Order::class],
            'places' => ['pending', 'paid', 'shipped', 'delivered', 'cancelled'],
            'transitions' => [
                'pay'     => ['from' => 'pending', 'to' => 'paid'],
                'ship'    => ['from' => 'paid',    'to' => 'shipped'],
                'deliver' => ['from' => 'shipped', 'to' => 'delivered'],
                'cancel'  => ['from' => ['pending', 'paid'], 'to' => 'cancelled'],
            ],
        ],
    ],
];

First use case — an Order model with a status column (string, or a PHP enum cast whose values match your place names):

use Sebdesign\SM\Facade as StateMachine;

if (StateMachine::can('pay', $order)) {
    StateMachine::apply('pay', $order);
    $order->save();           // the package does NOT persist for you
}

Where to look first

  1. config/state-machine.php — the shape of the array is the mental model for everything else (marking store, supports, places, transitions, guards, metadata).
  2. The StateMachine facade — can, apply, getEnabledTransitions are ~95% of real usage.
  3. Your model — decide up front whether the marking lives in a string column, an enum cast, or a getter/setter pair (marking_store.type => 'method').
  4. Symfony's Workflow/StateMachine object, reachable from the manager, when you need to introspect definitions or transitions programmatically.

Implementation Patterns

Keep definitions in config, not scattered in controllers. The config file is the single source of truth; the package lazily builds the underlying Symfony workflow from it. Controllers and actions should never list valid transitions by hand — ask the machine.

Wrap transition + persistence in a transaction. Since apply() mutates the model in memory and you save afterwards, put both in a DB::transaction(), then dispatch your own domain event after commit if side effects matter:

DB::transaction(function () use ($order) {
    StateMachine::apply('pay', $order);
    $order->save();
});

Put authorization in guards, not controllers. Transition-level guard config (or a listener on the guard event) keeps "can this user cancel this order" next to the transition definition. Use guards for policy, and keep validators for data (e.g. "can't ship an order with no items").

Listen on workflow events for side effects. Symfony dispatches workflow.<machine>.transition, .entered, .completed, .announce, and .guard.* through Laravel's event dispatcher, so you can register ordinary Laravel listeners for emails, audit rows, or Slack pings. Name them per machine (workflow.order.transition) to avoid cross-talk.

Drive UI from getEnabledTransitions(). Returning that list to Blade/Inertia means buttons appear/disappear automatically as the definition evolves — no more stale if ($order->status === 'pending') blocks.

Use metadata for presentation. Attach labels, colours, or icons to places and transitions in config and read them back, rather than hardcoding a status => label map in a view component.

Multiple machines per model is normal. An Order might have a fulfilment machine and a payment machine. Always reference the machine by name in calls; don't assume "the" machine for a model.

Testing. Because the machine is pure config + Symfony, feature tests can assert transitions without touching the UI: assert can() is false for invalid moves, assert apply() throws, assert listeners fired with Event::fake(). Build model factories with explicit status values so tests document the legal places.

Version note (v3.4.7). This release only widens framework compatibility (Laravel 13.x support, PR #76). No API, config, or behavioural changes — a drop-in upgrade from 3.4.6, and safe to adopt on Laravel 13 projects. If you pin illuminate/* tightly, composer update is all that's needed.

Gotchas and Tips

apply() does not save. It changes the marking on the in-memory model only. Forgetting save() is the single most common bug — the transition "works" in the request and vanishes on the next page load.

apply() throws when the transition isn't enabled. You'll get NotEnabledTransitionException, not false. Always gate with can() (or catch it) — especially in jobs and webhooks where the state may have changed since the page rendered.

Enum casts must match place names exactly. If status is cast to a PHP enum, place strings must equal the enum's value (or name, depending on backing), or the marking store silently reads as null and every transition looks disabled. Same trap with marking_store.type => 'method': the getter/setter names must actually exist on the model.

supports is matched by class. Subclasses generally resolve, but interfaces and abstract bases won't be inferred the way you might hope. If a model isn't in supports, you get "no workflow supports this subject"-style errors rather than a friendly message.

Config caching works, but stale caches bite. After editing config/state-machine.php in a cached environment, run php artisan config:clear (or re-cache) or you'll debug phantom behaviour for twenty minutes.

Closures in guards/validators are not serialisable. They're fine at runtime, but anything that tries to serialise the workflow definition (queued workflow objects, aggressive caching strategies) will fail. Prefer named guard expressions or event listeners when you need durability.

Events fire around the in-memory transition. Listeners on transition/entered/completed receive the model before your save() has necessarily run. If a listener queries the database by ID, it may see the old status. Either save before dispatching your own domain events, or use DB::afterCommit().

Typo'd place names fail quietly-ish. A transition whose to isn't in places will blow up at build time, but a from that's simply never reached just means a silently unreachable transition. Cross-check the place list against the transition list when adding states.

state_machine vs workflow is a real semantic fork. state_machine enforces exactly one place at a time (what most CRUD apps want); workflow allows many simultaneous places and needs a different mental model for the marking store. Picking the wrong one shows up as confusing guard behaviour rather than an error.

Escape hatch for advanced needs. Reach the underlying Symfony workflow from the manager to inspect definitions, list places, or render a diagram (Symfony's GraphvizDumper / MermaidDumper are handy for docs). Don't fight the wrapper — drop to the Symfony object rather than reimplementing transition logic.

Extension points. Custom marking stores, custom guard expressions, and additional event listeners are the intended seams. Anything that feels like "I need to change how apply works" is usually better expressed as a listener on completed.

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.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo