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.
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.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.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.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');
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.
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.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.config/state-machine.php to publish. Bind your machines in AppServiceProvider yourself, or accept constructing them per use — they're cheap.\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.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.can() in a controller and apply() in a later request without re-validating inside the transaction.$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.How can I help you explore Laravel packages today?