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

State Machine Laravel Package

caner/state-machine

A lightweight Laravel state machine for Eloquent models. Define states, transitions and guards, apply them safely with validation, events and history, and keep workflow logic out of controllers and models. Ideal for approvals, orders and multi-step processes.

View on GitHub
Deep Wiki
Context7

Getting Started

  1. Install with Composer: composer require caner/state-machine. Target 1.1.0, which adds official Laravel 9 support. There is still no service provider config to publish — the trait and contracts remain auto-discovered.

  2. Read the README on GitHub first, then open the example project now linked from the docs. That example is the fastest way to see the full lifecycle (model, transitions, and how the new automatic state update persists) before you read src/. The package is small (~1 trait + a few support classes), so the whole surface is readable in 15 minutes.

  3. Add the trait to an Eloquent model and declare the transition map on the model itself:

    use CanerErgez\StateMachine\HasStateMachine;
    
    class Order extends Model
    {
        use HasStateMachine;
    
        protected $stateField = 'status';
    
        protected $transitions = [
            'pay'    => ['from' => 'pending', 'to' => 'paid'],
            'ship'   => ['from' => 'paid',    'to' => 'shipped'],
            'cancel' => ['from' => ['pending', 'paid'], 'to' => 'cancelled'],
        ];
    }
    

    (Field/method names match the README — confirm exact keys against your installed version before relying on them.)

  4. First use case: an Order / Invoice / Subscription lifecycle. Start at a single transition driven from a controller or action:

    $order->transitionTo('paid'); // or $order->pay() if magic methods are enabled
    

    Because the write is wrapped in a DB transaction, you can drop this into an existing store/update flow without adding your own DB::transaction() around the save.

  5. New in 1.1.0: the state field is updated for you ("automatic state update"), so you should not need to call $order->save() after a transition. Check the example project for the expected call shape in your installed version.

  6. Verify by asserting $order->fresh()->status after the call and confirming an invalid transition throws.

Implementation Patterns

Model-level transition map. Keep states and transitions as plain arrays on the model (or a dedicated StateMachine config class referenced from the model). This keeps the graph greppable and avoids database-driven state definitions that are hard to test.

Thin controllers, fat action classes. Wrap each transition in a single-purpose action so the state change, side effects, and authorization live together:

class PayOrder
{
    public function __construct(private Order $order) {}

    public function handle(): void
    {
        $this->order->transitionTo('paid');
    }
}

This makes the transition mockable and keeps transitionTo out of controllers, where it tends to get tangled with request validation.

Let the automatic update own the write. With 1.x automatic state updates, keep the flow to "mutate through the transition, not through save()". If an action sets other attributes as well, set them before calling transitionTo() so everything lands in the same persisted write, and avoid a second redundant save():

$this->order->fill(['paid_at' => now()]);
$this->order->transitionTo('paid'); // persists the automatic state update

Authorize before transitioning. Run a policy check ($this->authorize('pay', $order)) before calling the transition. The package enforces the graph, not the actor — combining the two gives you row-level permissioning on top of the FSM.

Side effects via events, not inline. Listen for the transition event (or override onTransition if exposed) and dispatch a queued job for notifications, webhooks, and downstream sync. Keeping side effects out of the trait means a rolled-back transaction doesn't leak a sent email.

Idempotency at the edge. If a transition is triggered from a webhook or retried job, guard with $order->transitionTo(...) only after checking $order->status !== 'paid'. The package throws on invalid transitions, so a second call fails loudly — wrap in a try/catch and treat "already in target state" as success.

Testing. Use RefreshDatabase and model factories. Assert both the happy path (assertDatabaseHas) and the guard ($this->expectException(...)). Because transitions are transactional, no fixtures leak between tests. Add an explicit assertion that the row was persisted without your action calling save() — that pins down the automatic-update behaviour and will catch a regression if it ever changes.

API integration. For REST endpoints, map each transition to a PATCH /orders/{order}/pay-style route and let the action class own the call. Return 409 Conflict when the transition throws, 403 from the policy, 200 on success.

Gotchas and Tips

  • Laravel version drift. 1.1.0 officially adds Laravel 9 support, so Laravel 8/9 are the safe bets. Laravel 10/11 still require widening illuminate/* constraints or forking. Check composer.json requirements before committing.
  • Automatic state update is a behaviour change, not a flag. 1.1.0 changes who persists the write. Audit any code that previously did transitionTo(...) and save() — you may now be double-saving, or saving stale in-memory attributes over the new state. The same applies to code that relied on $order->status being unchanged until a manual save.
  • Verify the example project's Laravel version. The new per-doc example links are the best reference, but if the example targets Laravel 8/9 semantics, its automatic-update calls may look different from what you need on a newer framework.
  • Single maintainer, zero dependents. No one else is known to be running this in production. A docs/example refresh doesn't change that. Read the source rather than trusting docs; keep your own fork if you adopt it at scale, and treat upgrades as manual.
  • "Transaction-safe" ≠ concurrency-safe. Wrapping the read-check-write in a transaction prevents partial writes but not two concurrent requests both passing the guard. Add ->lockForUpdate() on the model before transitioning, or use a WHERE status = ? conditional update.
  • Events fire inside the transaction. If you dispatch a queued job inside the transition and the transaction rolls back, the job was already queued. Use dispatchAfterResponse or a DB::afterCommit listener for anything with external side effects.
  • Automatic updates can collide with observers. If your saving/updating observers rewrite the state field, you can silently clobber the automatic update. Keep observers off the state column.
  • Magic transition methods are not IDE-discoverable. If $order->pay() works, it's __call under the hood — you get no static analysis. Prefer the explicit transitionTo('paid') form for greppability.
  • Invalid transitions throw a generic exception. Catch it narrowly (InvalidTransitionException if provided, otherwise \RuntimeException) and translate to your domain error. Don't let it bubble up as a 500.
  • No state history by default. If you need an audit trail, add a separate state_transitions table and a listener that records from, to, actor, at — do not try to derive history from timestamps.
  • Debugging rollbacks. When a transition silently doesn't persist, run DB::listen(fn($q) => dump($q->sql, $q->bindings)) around the call. The most common causes are a failing observer/event listener inside the transaction, or an older code path that still does its own save() after the automatic update.
  • Extension points. Override the guard method (e.g. canTransitionTo) on the model to add custom rules; subclass the trait's resolver if you need a database-backed graph. Both are stable seams because the class is small.
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