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

Eloquent State Machine Laravel Package

bidzm/eloquent-state-machine

A Laravel package that adds state machine behavior to Eloquent models. Define states and allowed transitions, guard or authorize changes, and persist the current state automatically. Keeps model lifecycle logic clean and predictable, with hooks and events for reacting to transitions.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a small, single-maintainer package (1 star, 0 dependents), so treat the source as the canonical documentation. Before wiring it into a model, read src/ and the trait it exposes — the README is thin and the API surface is small enough to audit in ten minutes.

Minimal setup:

composer require bidzm/eloquent-state-machine

If the package ships a config file, publish it; otherwise skip straight to the model:

php artisan vendor:publish --provider="Bidzm\StateMachine\StateMachineServiceProvider"

Add the trait to the Eloquent model that owns the state column, and declare the state/transition map the way the trait expects (check the trait's docblock or the $stateMachine / stateMachine() method signature in src/ for the exact shape):

use Bidzm\StateMachine\...; // confirm the trait namespace from src/

class Order extends Model
{
    use HasStateMachine;

    protected $stateMachine = [
        'column'  => 'status',
        'initial' => 'pending',
        'states'  => ['pending', 'paid', 'shipped', 'completed', 'cancelled'],
        'transitions' => [
            'pay'    => ['from' => ['pending'],           'to' => 'paid'],
            'ship'   => ['from' => ['paid'],              'to' => 'shipped'],
            'finish' => ['from' => ['shipped'],           'to' => 'completed'],
            'cancel' => ['from' => ['pending', 'paid'],   'to' => 'cancelled'],
        ],
    ];
}

Where to look first: src/ for the trait and any transition/state value objects, then the model's migration to confirm the column name and default. First use case: replace ad-hoc $order->status = 'paid'; $order->save(); calls in a checkout flow with a named transition guarded by the state machine. That single swap catches the most common production bug — double-paying or shipping a cancelled order.

Implementation Patterns

Wrap transitions in intention-revealing methods. Don't sprinkle $model->transition('pay') across controllers. Put it behind a model method or an action class:

class Order extends Model
{
    public function markPaid(): void
    {
        $this->transitionTo('paid'); // or ->transition('pay') — match the trait API
    }
}

Validate the requested transition at the edge. A form request or controller check should ask the machine first and fail loudly:

public function pay(Order $order)
{
    abort_unless($order->canTransitionTo('paid'), 409, 'Illegal transition');
    $order->markPaid();
}

Use 409 Conflict (not 422) for illegal transitions — the payload was fine, the resource state was not.

Keep the state column out of mass assignment. Add status to $guarded or drop it from $fillable. Otherwise a stray Order::create($request->all()) bypasses the machine entirely. The machine is only a guard if the column can't be written directly.

Authorize transitions separately from validity. The state machine says whether a transition is legal; policies say whether this user may do it:

$this->authorize('pay', $order);
abort_unless($order->canTransitionTo('paid'), 409);
$order->markPaid();

Drive UI from the same predicates. In Blade/Livewire, render buttons conditionally so the UI and the guard can't drift:

@if ($order->canTransitionTo('shipped'))
    <button wire:click="ship({{ $order->id }})">Ship</button>
@endif

Use constants or a PHP enum for state names. Typos in string states are the #1 failure mode. A backed enum plus a cast keeps the model's status attribute honest:

enum OrderStatus: string { case Pending = 'pending'; /* ... */ }

protected $casts = ['status' => OrderStatus::class];

Confirm the trait handles enum-cast attributes; if not, expose a string accessor for the machine and keep the enum cast on a separate attribute.

Persist an audit trail. The package tracks current state, not history. Add a state_transitions table and write a row inside the same DB transaction as the state change (observer or explicit call). You will be asked "who cancelled this?" within a month.

Testing pattern. Use RefreshDatabase and assert both directions:

it('cannot ship a pending order', function () {
    $order = Order::factory()->create(['status' => 'pending']);
    expect($order->canTransitionTo('shipped'))->toBeFalse();
});

it('records the transition', function () {
    $order = Order::factory()->create(['status' => 'pending']);
    $order->markPaid();
    expect($order->fresh()->status)->toBe('paid');
});

Data-provider the entire transition matrix — legal and illegal pairs — so a map edit forces a test update.

Gotchas and Tips

  • Verify the API before you build on it. With 1 star and 0 dependents there is no Stack Overflow corpus. Read the trait, note the exact method names (transitionTo, transition, canTransitionTo, can, etc. — they vary between minor versions of small packages), and pin the version in composer.json ("bidzm/eloquent-state-machine": "1.*" or better, an exact tag).

  • Persist before you transition. If your code calls a transition on an unsaved model, most implementations either throw or silently no-op. Always create() first, or explicitly document that the initial state is set by the DB default rather than the machine.

  • Concurrency. Two requests can both read pending, both pass canTransitionTo('paid'), and both write. Wrap read-check-write in a transaction with lockForUpdate():

    DB::transaction(function () use ($order) {
        $order = Order::lockForUpdate()->find($order->id);
        abort_unless($order->canTransitionTo('paid'), 409);
        $order->markPaid();
    });
    
  • Don't transition inside accessors, booted() observers, or saving(). You'll get recursive saves or transitions that fire on unrelated updates (e.g. editing a shipping address). Keep transitions in explicit service methods.

  • Initial state. Confirm what the package returns for a model whose state column is null or holds an unlisted value. Decide whether to backfill with a migration and add a DB-level default plus a CHECK/enum constraint so the column can never hold garbage.

  • Failure mode. Small packages tend to throw a generic \Exception or return false on an illegal transition. Catch it at the boundary and convert to a domain exception (IllegalOrderTransition) so your API returns a consistent 409 rather than a 500.

  • Extension points. Most traits in this space are protected-heavy and designed for subclass overrides. If the trait's transition lookup doesn't fit (e.g. you need role-specific maps), override the map/provider method in the model rather than forking the package.

  • Wrap it. Given zero dependents and one maintainer, define your own thin interface (OrderStateMachine) and have one adapter class call the package. If the package goes unmaintained — a real risk here — you swap the adapter, not 40 call sites.

  • Debugging. Log the from-state, to-state, transition name, model class, and ID at debug level inside your wrapper. When someone reports "the button did nothing," that log line tells you whether the guard fired, the transition name was misspelled, or the page was cached.

  • Blade/view caching. If canTransitionTo results are cached in a view composer, remember to forget the cache on save or you'll render stale buttons. Simpler: compute per-request, no caching.

  • Evaluate alternatives if the map grows. For anything beyond a linear lifecycle, compare against spatie/laravel-model-states (typed state classes, richer guards, actively maintained) or a hand-rolled enum + match guard. For a five-state order flow, this package is adequate; for parallel state dimensions or sub-states, it isn't.

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.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style