acacha/stateful-eloquent
Stateful Eloquent adds state handling to Laravel models, letting you define and transition between states with clean, expressive syntax. A lightweight helper for tracking model status without extra dependencies.
acacha/stateful-eloquent is a small Laravel-5-era trait that bolts a finite state machine onto an Eloquent model: you declare allowed states and transitions as arrays on the model, and the trait exposes state, transitionTo(), canTransitionTo(), and isState()-style helpers. It is a thin convenience layer, not a workflow engine — which is exactly why it is easy to adopt and easy to outgrow.
Install and wire up
composer require acacha/stateful-eloquent
On Laravel 5.5+ service-provider auto-discovery usually handles registration. On older 5.x apps, add the provider manually in config/app.php:
Acacha\Stateful\StatefulServiceProvider::class,
Check the README for a publishable config and run php artisan vendor:publish only if one exists in your version — many releases have none.
Schema
Add a string column to the table (the package expects a state attribute unless you see otherwise in the trait):
$table->string('state')->default('draft');
A DB-level default is worth setting so rows created outside the model (seeders, raw inserts) are not stuck on null.
Model
use Acacha\Stateful\Traits\StatefulTrait;
class Post extends Model
{
use StatefulTrait;
protected $states = [
'draft' => ['initial' => true],
'review' => [],
'published' => ['final' => true],
];
protected $transitions = [
'submit' => ['from' => ['draft'], 'to' => 'review'],
'publish' => ['from' => ['review'], 'to' => 'published'],
'reject' => ['from' => ['review'], 'to' => 'draft'],
];
}
Where to look first
src/Traits/StatefulTrait.php — this is the whole package. Read it once end-to-end; you will want to know whether transitionTo() saves the model or only mutates it, and what exceptions it throws.First use case
The classic draft → review → published content flow. In a controller: load the post, call canTransitionTo('review'), redirect with an error if false, otherwise transitionTo('review') and save() if the trait does not persist for you. That single guard-and-transition pattern is 90% of real usage.
Controller with explicit guards
public function submit(Post $post)
{
if (! $post->canTransitionTo('review')) {
return back()->withErrors('This post cannot be submitted from its current state.');
}
$post->transitionTo('review');
$post->save();
return back();
}
Keep the user-facing action (submit) separate from the target state (review) — this reads better, matches the $transitions keys, and lets two actions converge on the same state later.
Form Requests for validation
Push the state check into a FormRequest::authorize() or a custom rule so controllers stay thin. A Rule that calls canTransitionTo() gives you a single place to test and reuse across web and API requests.
Policies and authorization
State is often a permission concern. In a policy method:
public function submit(User $user, Post $post)
{
return $user->id === $post->user_id && $post->canTransitionTo('review');
}
This separates "is this transition legal" from "is this user allowed to attempt it", which matters as soon as you have roles.
Expose available actions to the UI
Derive button visibility from the machine instead of hard-coding @if ($post->state === 'draft') in Blade:
@if ($post->canTransitionTo('review'))
<button formaction="{{ route('posts.submit', $post) }}">Submit for review</button>
@endif
For APIs, serialize the state plus a computed available_transitions list in your Resource so front-ends do not duplicate the state graph.
Query scopes
All state lives in one column, so scopes are trivial and fast:
public function scopeState($query, string $state)
{
return $query->where('state', $state);
}
Index the column if you filter or dashboard on it heavily.
Wrap transitions plus side effects in a transaction
A transition usually implies side effects: notifications, audit rows, search indexing. Do all of it inside DB::transaction() so a failed mail job does not leave the model in a new state with no record of why.
Testing
Use RefreshDatabase and factory states to land models in a given state, then assert transition results:
$post = Post::factory()->create(); // draft
$this->assertTrue($post->canTransitionTo('review'));
$this->assertFalse($post->canTransitionTo('published'));
Also test the negative path — an illegal transition should throw or return false, and you should decide which and assert it.
Scheduled and job-driven transitions
For auto-publishing or expiry, an Artisan command that queries Post::state('review')->where('updated_at', '<', now()->subDays(7)) and calls transitionTo('published') is far easier to reason about than putting state logic in observers.
Version and maintenance reality check. This is an 11-star Laravel 5 package. Its composer constraints may block install on Laravel 8/9/10/11, and it is effectively unmaintained. Before committing to it on a modern app, try composer require in a scratch branch; if it fails, either fork it (the trait is a few hundred lines) or use spatie/laravel-model-states, which has the same mental model plus typed states, transition classes, and active maintenance. Do not discover this on day three of a greenfield build.
Know whether transitionTo() saves. This is the single most common source of "why is my state not persisted?" bugs. Read the trait and pick one convention — either it saves and you never call save() again, or it does not and you always do. Mixing the two inside one codebase produces double saves and duplicate model events.
Bypassing the machine is easy. $model->state = 'published' and update(['state' => 'published']) will happily skip every guard. Keep state out of mass-assignment paths ($fillable or $guarded) for controller input, and treat the column as write-only through transitionTo(). If you must set it directly (imports, migrations), do it in a clearly marked place.
No built-in transition events. You get the state changed but not a hook. Use an Eloquent observer on updated and compare getOriginal('state') to state to dispatch your own PostStateChanged event. That event then becomes the extension point for notifications, audit logs, and analytics.
Concurrency. Two requests can read the same state and both pass canTransitionTo(). For anything user-triggered and parallel (double-click, two admins), guard the write with lockForUpdate() inside a transaction, or use a conditional update — where('state', $expected)->update(['state' => $to]) — and treat 0 affected rows as a lost race.
Initial state on creation. If the DB default is not set and nothing assigns an initial state, new models have null and every canTransitionTo() returns false with no useful error. Add a creating observer that falls back to the state marked initial => true, or rely on the column default deliberately.
Final states are advisory. If the package supports a final flag, verify it actually blocks outgoing transitions in your version; if not, enforce "no transition from published" in your transition table and add a test that proves it.
No transition history. The package stores current state only. If you need an audit trail (you usually do once legal or support gets involved), add a state_transitions table and write a row in the observer alongside the transition. Do this before production, not after.
Debugging checklist. When a transition misbehaves: dd($model->state, $model->canTransitionTo('review')); confirm the $transitions key name and that from is an array, not a string (a classic array-shape error); confirm the column is actually state and not renamed; check the model was refreshed from the DB, not stale in memory.
Extension points. Subclass-level overrides on the model (custom stateLabel(), availableTransitions()), observers for side effects, policies for permission, and a small service class per workflow if the transition table grows past ~8 entries. At that point the arrays become unreadable and typed transition classes earn their keep.
Naming collisions. $model->state is a column/accessor; do not also add a state() relationship or method with the same name — Eloquent and the trait will disagree about what state means and you will chase phantom bugs.
How can I help you explore Laravel packages today?