aw-studio/laravel-states
Laravel States adds state machine support to Eloquent models, letting you define states, transitions, and guards cleanly. Track model status, control allowed changes, and keep your workflows predictable without extra boilerplate.
Install and publish
composer require aw-studio/laravel-states
php artisan vendor:publish --tag=states-migrations
php artisan migrate
Attach a state machine to a model
use AwStudio\States\HasStates;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasStates;
protected $states = [
'status' => [
'initial' => 'pending',
'transitions' => [
'pay' => ['pending' => 'paid'],
'ship' => ['paid' => 'shipped'],
'refund' => ['paid' => 'refunded'],
],
],
];
}
First use case — an order lifecycle
$order = Order::create([...]); // "status" row starts at the configured initial state
$order->status->transition('pay'); // pending → paid
$order->status->is('paid'); // true
This $model->{type}->transition() form is the supported API. The relation-level call $order->states()->transition(...) was removed in v2.0.1 and must not be reintroduced.
Where to look first
AwStudio\States\HasStates — the states() morph relation and the magic __get accessors that expose $model->status, $model->payment_status, etc.type + current state value.State object returned by $model->status — this is where transition(), is() and the guard helpers actually live.Multiple independent state dimensions. Define several keys in $states (status, payment_status, fulfillment) and transition them separately. Each becomes its own magic property; don't cram orthogonal concerns into one machine.
Authorize at the boundary, not in the model. Keep transition names business-meaningful (pay, ship) and gate them in a policy or form request:
public function pay(Order $order)
{
$this->authorize('transition', [$order, 'pay']);
$order->status->transition('pay');
}
Wrap in a transaction and dispatch your own domain events. A transition writes to the states table, so do it inside DB::transaction() when it accompanies side effects (inventory decrement, invoice creation). The package gives you the state change; the surrounding action is yours.
Eager load the relation for index pages. Listing rows and rendering $order->status one-by-one causes a query per model. Use Order::with('states')->get() so the states rows come along with the parents.
Custom state classes / extension. The per-dimension state object is the natural extension point: subclass it if you want named helpers like $order->status->isPaid() or a label() for the UI, and keep those helpers as the only thing views touch.
Blade / API output. Read the value out of the state object rather than the raw column, and expose a stable string ($order->status->getValue() or (string) $order->status) so the API contract doesn't leak the object.
v2.0.1 removed States::transition() — the big one. $model->states()->transition(...) called a method that never existed and always threw. If you had it in a try/catch or a "if method exists" branch, that branch is now dead and the call will throw BadMethodCallException. Migrate every occurrence to $model->{type}->transition('name'):
// before (always errored)
$order->states()->transition('pay');
// after
$order->status->transition('pay');
Because the old call never worked, this is only a real breakage for code that deliberately guarded around it — but grep your codebase anyway, since any surviving call now fails with a different error message and may escape an existing catch.
states() is still there — use it for reading, not transitioning. The relation remains the right way to eager load and iterate all dimensions ($order->states), just don't call transition() on it anymore.
Magic properties can shadow real columns. If your table already has a status column, the trait's accessor and the attribute will fight over the same name. Either rename the dimension (workflow_status) or drop the column; don't keep both in sync by hand.
Records created before the package (or before a new dimension) have no state row. Backfill them in a migration, or make the first read tolerant — a missing row will otherwise blow up on $model->status->transition().
Missing transition = exception. Calling an undeclared transition name, or one whose from state doesn't match the current state, is an error rather than a no-op. Guard with the State object's canTransition* helper (verify the exact method name against the installed class) and return a 422 from your controller instead of letting a 500 escape.
Tests. Use RefreshDatabase and assert on a fresh instance — $order->fresh()->status->is('paid') — because the in-memory model's state object can be stale after a transition performed elsewhere.
Config/module caching. $states is defined on the model class, so it is picked up on deploy automatically, but if you cache any generated list of transitions (for a UI dropdown) remember to bust it whenever the model's $states array changes.
How can I help you explore Laravel packages today?