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

juststeveking/state-machine

A lightweight state machine for Laravel and PHP applications. Define states and allowed transitions, then move models or objects through workflows with clear, testable rules and guard conditions.

View on GitHub
Deep Wiki
Context7

Getting Started

  • composer require juststeveking/state-machine. This is a framework-agnostic PHP 8 package, not a Laravel bundle: expect no service provider, no publishable config, no migrations, no Artisan commands. There is nothing to scaffold — you wire it into your app yourself.
  • Read vendor/juststeveking/state-machine/src in this order: the State and Transition value objects, the StateMachine class, then Exceptions/. The whole surface area is small enough to read in one sitting, and doing so is faster than guessing from the README.
  • First use case: pick one column that is currently mutated ad hoc — orders.status, subscriptions.state, tickets.stage — and make it the single source of truth for a machine. Define the states, define the legal edges between them, and route every write to that column through the machine.
  • Sketch of the day-to-day shape (pseudo-code — confirm exact signatures against the README/src, the README is the only documentation shipped):
// 1. declare the graph (once, in a dedicated class or config file)
$machine = new OrderStateMachine();   // states + transitions + guards

// 2. ask questions — cheap, side-effect free, safe in views/policies
$machine->can($order->status, 'publish');          // bool
$machine->allowed($order->status);                 // ['publish', 'archive']

// 3. apply — throws when the edge does not exist
$machine->apply($order, 'publish');
  • Get the "happy path" working end to end on one model — controller → machine → persisted column → redirect — before adding guards, events, or history.

Implementation Patterns

Keep the graph declarative and centralised. Define transitions as data (an array in config, or attributes on a backed enum) rather than as if statements scattered through services. One machine class per aggregate (OrderStateMachine, InvoiceStateMachine), registered in a small container binding or a StateMachineFactory so controllers and jobs resolve it rather than new it up. This is the difference between a package you use and a package you fight.

Back states with an enum, cast on the model. protected $casts = ['status' => OrderStatus::class] gives you type safety at the boundary, and the machine compares scalars/enum cases consistently. Whatever you choose (strings or enum cases), choose once — mixing them is the most common source of "transition not found" bugs.

Wrap check-and-apply in a transaction with a row lock. The machine validates and mutates in memory; it does not protect you from two requests applying publish concurrently. Do:

DB::transaction(function () use ($order) {
    $order = Order::lockForUpdate()->find($order->id);
    $this->machine->apply($order, 'publish');
    $order->save();
});

Put the machine behind an action/service, not in the controller. A PublishOrder invokable that loads, locks, applies, saves, and returns the model keeps HTTP concerns (authorisation, validation, redirects) separate from transition rules and makes the same operation reusable from a queued job or a console command.

Authorisation and validation read from the machine. In a policy: return $machine->can($order->status, 'publish');. In a FormRequest: ['transition' => ['required', Rule::in($machine->allowed($order->status))]]. This keeps your UI honest — if a button can't be rendered, the request can't be forged either.

Render available transitions, don't hardcode buttons. Map $machine->allowed($model->status) to a set of <button>s (or Filament/Nova actions). When you add an edge to the graph the UI updates for free, which is the main payoff of the package over hand-rolled switch statements.

Side effects via events, fired after commit. Listen for the model's own updated/domain event, or dispatch your own OrderPublished from the action. If you dispatch inside the transaction, use DB::afterCommit() so a mailer never sends for a rolled-back transition.

Write your own history table. The package gives you a state machine, not an audit log. Add order_transitions (from, to, transition, actor_id, created_at) and insert from the action. You will want this within a month of shipping.

Test the graph, not just the endpoint. Feed a data provider of [from, transition, expectedTo] and [from, transition, expectedException] pairs straight into the machine — it has no DB dependency, so these are pure unit tests and run in milliseconds. Then one feature test per action for the persistence/lock path.

Gotchas and Tips

  • 1.0.0 ships an empty changelog. The release notes are a bare link, so before upgrading any existing 0.x install, diff the src/ tree or scan the commit history for renamed classes and changed constructor order. Pin ^1.0 now — the 1.0 tag is the maintainer's signal that the API is stable, and that is genuinely the useful information in this release.
  • The package does not persist anything. No model, no migration, no HasStateMachine trait to reach for. You own the column, the cast, and the save. If you expect $machine->apply() to write to the database, you will be surprised.
  • It is not Laravel-aware. No service provider means no auto-registered singleton and no config/state-machine.php to publish. Bind your machines in AppServiceProvider yourself, or accept constructing them per use — they're cheap.
  • Catch the package's specific exceptions, not \Exception. An invalid transition should surface as a 409 Conflict or a 422, not a 500. Add a render() on the exception or an explicit catch in the action, and let genuine bugs fall through.
  • Closures in transition guards break config:cache. If you define graphs in a config file with closure guards, php artisan config:cache will fail or silently mangle them. Use invokable guard classes referenced by class-string instead.
  • Check-then-act without a lock is a race, as above. Never call can() in a controller and apply() in a later request without re-validating inside the transaction.
  • Keep the machine stateless and per-request. Don't cache a machine instance across jobs in a long-running worker if its graph is built from mutable config; rebuild or bind as a singleton only when the graph is genuinely immutable.
  • Debugging: dump $machine->allowed($from) when a transition "doesn't exist" — nine times out of ten the failing value is an enum case being compared to a string, or a null on a fresh record with no default. Add a temporary log line in the action recording from → transition → to; it doubles as a poor man's audit trail until you build the table.
  • Extension points worth knowing: custom state/transition value objects and guard implementations. If your transitions need payloads (a reason, an approver), model that as a transition class rather than stuffing it into your service — it keeps the graph the single description of what is legal.
  • Scope discipline: this package answers "is this move legal?" It does not do workflows with parallel branches, sub-processes, or timers. For anything beyond a linear/passing-through lifecycle, reach for a workflow engine rather than bending the graph.
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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core