christhompsontldr/laravel-fsm
A plug-and-play finite state machine for Laravel with guards, actions, callbacks, events, and transition logging. Define states and transitions per model column to power order workflows, verification flows, and more.
Install the package and publish its config — the defaults are usable, but you want to see the four knobs that change runtime behaviour:
composer require christhompsontldr/laravel-fsm
php artisan vendor:publish --provider="Fsm\FsmServiceProvider" --tag="fsm-config"
Every FSM is three artifacts in your app, plus a trait on the model:
// 1. States — string-backed enum implementing FsmStateEnum
namespace App\Fsm\Enums;
enum OrderStatus: string implements FsmStateEnum
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
public function label(): string { return ucfirst($this->value); }
}
// 2. Definition — auto-discovered from app/Fsm, implements FsmDefinition
namespace App\Fsm\Definitions;
class OrderStatusFsm implements FsmDefinition
{
public function define(): void
{
FsmBuilder::for(Order::class, 'status')
->initialState(OrderStatus::Pending)
->state(OrderStatus::Paid)
->from(OrderStatus::Pending)->to(OrderStatus::Paid)->event('pay')
->from(OrderStatus::Paid)->to(OrderStatus::Shipped)->event('ship')
->build();
}
}
// 3. Model
class Order extends Model { use \Fsm\Traits\HasFsm; }
Then drive it by event name, not by target state:
$order->fsm()->trigger('pay');
$order->fsm()->can('ship'); // bool, non-mutating
$order->fsm()->dryRun('ship'); // ['can_transition' => true, 'from_state' => ..., 'to_state' => ...]
Where to look first. config/fsm.php (default_column_name, use_transactions, event_logging, logging) because those four settings explain most surprises later. Then the HasFsm trait itself — fsm(), getFsmState(), transitionFsm() — that's the entire public surface a controller will touch. Then Fsm\Events\StateTransitioned, which is your integration point for audit logs and side effects.
First use case. Model a status column you already have (orders, verification, moderation). Do not retrofit a boolean or a text column with hand-rolled if checks — move the legal transitions into a definition and replace each if with trigger()/triggerIf(). Immediately after writing the definition, run php artisan fsm:diagram and open storage/app/fsm-diagrams/Order_status.puml: if the graph doesn't look right, your definition isn't registered the way you think it is.
Event names are your app-facing API. Controllers, jobs, and console commands should call trigger('ship'), never transitionFsm('status', OrderStatus::Shipped). The event name is the stable contract; routing lives in the definition, so reorganising the graph doesn't ripple into fifty call sites. Reserve transitionFsm() for migrations, seeders, and admin repair tooling.
Split check-then-act by context. Interactive UI: can() to decide whether to render the button, trigger() to execute. Webhooks, queued jobs, and anything retryable: triggerIf(), which is a no-op returning false rather than an exception — exactly right for an at-least-once delivery world.
Put each concern in its own hook. Guards for preconditions (EnsurePaymentAuthorized), action() for synchronous side effects inside the transition (RecordPaymentMetrics), onEntry() callbacks on the state for reactions that belong to arriving somewhere (SendReceipt), and queuedAction()/a listener for slow work (notifications, webhooks out). This keeps the definition readable and makes it obvious what runs before the state changes versus after.
One FSM per column, addressed explicitly. For a document with approval and publication lifecycles, register two definitions and always pass the column: $doc->fsm('approval_status')->trigger('approve'). Mixing up columns is silent corruption, so be consistent — bare fsm() should mean "the default column" and nothing else.
Wildcards for admin overrides, always guarded. \Fsm\Constants::STATE_WILDCARD as a from() is how you get force_cancel from any state. Because it opts out of the graph's structural protection, pair it with a guard that checks permission and current state explicitly; otherwise it becomes a backdoor around every invariant you wrote.
Enums for events, once you have more than a handful. v1.0.5 added FsmEventEnum; trigger(CheckoutEvent::Submit) works everywhere a string does, and it makes typos a compile-time problem instead of a runtime silent no-op.
Observe one event, not many. A single Event::listen(StateTransitioned::class, ...) gives you getModel(), getFromState(), getToState(), getColumn() — enough for audit rows, cache invalidation, and Slack pings without touching the definitions.
Testing. dryRun() assertions read better than catch-and-assert on exceptions. Event::fake() on StateTransitioned lets you assert the transition happened without asserting on the model twice. Keep a small table-driven test per definition: every from/event pair should be allowed, and a representative sample of illegal pairs should be rejected.
php artisan fsm:cache:clear is mandatory after editing a definition, and belongs in your deploy script. Symptom of forgetting it: a transition that exists in code but "doesn't exist" at runtime.app/Fsm by default. The v1.0.3 changelog mentions a discovery_paths setting, but the README's config block doesn't show it — check your published config/fsm.php before moving definitions into app/Domain/*/Fsm, or your definitions will silently not register.use_transactions defaults to true. Your actions and callbacks run inside a transaction. Two consequences: an exception in a later callback rolls back the state change (usually desirable), and a dispatched job may fire before commit — use afterCommit/queuedAction rather than raw dispatch() inside an action.trigger() is the strict path, triggerIf() is the safe one. The README describes triggerIf() as the form that works "without throwing," which implies trigger() surfaces a denied transition as an exception. Decide per call site and don't let a guard failure become a 500 on a user-facing route.->state() is optional for the graph, load-bearing for behaviour. The multi-FSM examples register only from/to pairs, so a diagram will render without ->state() calls — but onEntry(), metadata(), and isTerminal(true) only exist on a declared state. If a terminal flag matters to your invariants, back it with a guard rather than trusting the label alone.->from()->to()->event() extends the definition in place; the block style ->transition()->from(...)->to(...)->event(...)->add() commits one transition explicitly. Either way, forgetting ->build() leaves the definition unfinalised.log_failures is false. The moment you're debugging "why won't this transition fire?", set it to true — the default audit trail only shows successes, which is precisely the wrong half when something is blocked.event_logging.queue trades durability for latency. Queued persistence means audit rows lag the state change; v1.0.4 hardened error handling in PersistStateTransitionedEvent, so check where those rows actually land in your schema before you promise an auditor a complete trail.->initialState('draft') are legal and fine for throwaway workflows, but getFsmState() typing degrades and you lose label(). If you go enum, persist ->value and consider an Eloquent cast so $order->status and getFsmState() agree — and match your migration's column default to initialState.transitionFsm() bypasses guards entirely. It's the right tool for a data-fix migration and the wrong tool in a controller.^1.0 in composer.json, and when the README is thin on a detail, read the definition classes under Fsm\ — the source is the documentation.How can I help you explore Laravel packages today?