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.
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.
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.
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.)
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.
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.
Verify by asserting $order->fresh()->status after the call and confirming an invalid transition throws.
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.
illuminate/* constraints or forking. Check composer.json requirements before committing.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.->lockForUpdate() on the model before transitioning, or use a WHERE status = ? conditional update.dispatchAfterResponse or a DB::afterCommit listener for anything with external side effects.saving/updating observers rewrite the state field, you can silently clobber the automatic update. Keep observers off the state column.$order->pay() works, it's __call under the hood — you get no static analysis. Prefer the explicit transitionTo('paid') form for greppability.InvalidTransitionException if provided, otherwise \RuntimeException) and translate to your domain error. Don't let it bubble up as a 500.state_transitions table and a listener that records from, to, actor, at — do not try to derive history from timestamps.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.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.How can I help you explore Laravel packages today?