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.
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:
composer require bjuppa/eloquent-state-machine (Laravel auto-discovery should register the service provider; if not, add it manually to config/app.php).$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.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' => [],
];
}
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.
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.
"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.$states direction, the transition method names, exception types) so an upgrade doesn't require re-reading src/.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.null state on create may behave unexpectedly — test model creation explicitly.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.$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.booted() hooks or observers that touch the state column, you can re-enter the machine. Keep observers read-only with respect to state.$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.1.0 documents the behaviour you're relying on.How can I help you explore Laravel packages today?