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 Fsm Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • Definitions are cached. 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.
  • Discovery is 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.
  • Two builder styles — don't mix them up. Chained ->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.
  • Pick enum or string states, not both. Plain strings like ->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.
  • Escape hatches exist; treat them as such. transitionFsm() bypasses guards entirely. It's the right tool for a data-fix migration and the wrong tool in a controller.
  • Small ecosystem, so pin it. Eight stars and zero dependents means no Stack Overflow safety net: constrain to ^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.
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.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource