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

bjuppa/eloquent-state-machine

Add state machine behavior to Laravel Eloquent models. Define states, transitions, and guards, then move records through your workflows with a clean, expressive API that keeps business logic organized, predictable, and easy to test.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a pre-1.0 package (v0.1.0) whose author explicitly states the API is intended to be stable for 1.0, but that tests and documentation are absent. Treat the source in src/ as the specification until the docs land — budget 20–30 minutes to read the trait and the state-machine class before writing any code.

Minimal setup:

  1. composer require bjuppa/eloquent-state-machine (Laravel auto-discovery should register the service provider; if not, add it manually to config/app.php).
  2. Add a string column to hold the state, e.g. $table->string('state')->default('draft')->index();. Every model using the package needs this column — pick the name deliberately, because it becomes part of your public data model.
  3. Add the package trait to the model and declare the allowed transitions:
use Bjuppa\EloquentStateMachine\StateMachineTrait; // verify exact namespace in src/

class Order extends Model
{
    use StateMachineTrait;

    protected $states = [
        'draft'     => ['pending', 'cancelled'],
        'pending'   => ['paid', 'cancelled'],
        'paid'      => ['shipped'],
        'shipped'   => [],
        'cancelled' => [],
    ];
}
  1. Confirm the direction of the map from the source. It is almost certainly "current state => states it may move to", but if you assume wrong you will silently invert your entire workflow.

First use case: guarding a single status change in a service class rather than letting controllers write $order->state = 'paid' directly. Get one guarded transition working end-to-end (including the failure path) before rolling the trait across several models.

Implementation Patterns

Guard the transition, don't just set the attribute. The value of the package is the check, so route every state change through the state machine object rather than mass-assigning the column:

if (! $order->state()->canTransitionTo('paid')) {
    throw new InvalidTransitionException(...);
}

$order->state()->transitionTo('paid');

Wrap the check and the transition in the same DB::transaction() — a check-then-write across two statements is a race unless the row is locked (->lockForUpdate()).

Put transitions behind intention-revealing service methods. OrderFulfillment::markPaid($order) reads better than $order->state()->transitionTo('paid') scattered across five controllers, and gives you one place to add side effects, events, and logging later.

Use string constants or a PHP enum for state names. With no docs and no tests, typos in state strings fail silently or throw deep inside the package. A final class OrderState { const DRAFT = 'draft'; ... } (or an 8.1 backed enum if the package accepts it — verify) makes the $states map self-checking and greppable.

Validate before you transition, at the HTTP boundary. A form request rule that checks the target state against the currently allowed transitions produces a clean 422 instead of a 500 from inside the state machine:

public function rules(): array
{
    return ['state' => ['required', Rule::in($this->route('order')->state()->allowedTransitions())]];
}

(Confirm the accessor name; if it does not exist, derive it from the $states map yourself.)

Centralise side effects via model events. Eloquent updating/updated observers still fire on the underlying save, so an observer that reacts to isDirty('state') is the least invasive place to trigger emails, webhooks, or audit rows. Keep the observer idempotent — it will fire on any save that touched the column, not only on machine-driven transitions.

Per-model machines. If different models need different transition logic, the package likely supports subclassing the state machine or overriding a factory method on the trait. That is the intended extension point for anything beyond a static graph (e.g. transitions conditional on the user's role) — verify in src/ before inventing your own layer.

Testing. With no package test suite, your own feature tests are the contract. Use model factories to place a record in a known state (->create(['state' => 'pending'])) and assert both the happy path and that an illegal transition leaves the row unchanged.

Gotchas and Tips

  • Pin the version. "bjuppa/eloquent-state-machine": "0.1.*" — a 0.x package reserves the right to break the API, and the author has signalled 1.0 is coming. Review the diff before every bump.
  • The missing docs are the main risk, not the missing tests. The tests matter for you only insofar as they'd document intent. Since they don't exist, write a short internal README capturing the exact API surface you rely on ($states direction, the transition method names, exception types) so an upgrade doesn't require re-reading src/.
  • Block mass assignment of the state column. Add state to $guarded or omit it from $fillable. Otherwise a stray $model->update($request->all()) bypasses the state machine entirely and your invariants are decoration.
  • Initial state. Decide whether new records get their first state from the DB default, a model attribute default, or an explicit call. If the trait validates the current state on save, a null state on create may behave unexpectedly — test model creation explicitly.
  • State column naming. If you already have a status column, don't add a second one; instead check whether the trait's column name is configurable (property or config). Two near-synonymous columns is a permanent source of bugs.
  • Debugging failed transitions. When a transition throws, dump both the current state and the raw $states entry for it — the most common cause is a case mismatch or a state key that doesn't exist in the map at all.
  • Watch package/model event ordering. If you also use booted() hooks or observers that touch the state column, you can re-enter the machine. Keep observers read-only with respect to state.
  • Don't model long-lived processes as one big graph. The package's static $states map gets unwieldy past ~8 states. Split into multiple machines on separate columns (e.g. payment_state, fulfilment_state) rather than building a lattice.
  • Upstream lever: the release notes point at issue #5 asking for tests and docs. If you adopt this in production, comment there with the API surface you depend on — it is cheap, and it materially improves the odds that 1.0 documents the behaviour you're relying on.
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.
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log
hsluv/hsluv
seampact/codegen
seampact/hooks
seampact/ir
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk