iben12/laravel-statable
Laravel Statable makes it easy to add sortable, searchable, paginated data tables to Laravel apps. Define columns and filters on your Eloquent model with a simple trait, then render responsive server-side tables using Blade components and minimal setup.
Install and wire up (3 steps):
composer require iben12/laravel-statable
string or enum):Schema::table('orders', function (Blueprint $table) {
$table->string('state')->default('pending')->index();
});
use Iben12\LaravelStatable\Traits\Statable;
class Order extends Model
{
use Statable;
protected $states = ['pending', 'paid', 'shipped', 'cancelled'];
protected $transitions = [
'pending' => ['paid', 'cancelled'],
'paid' => ['shipped'],
];
}
$order->transitionTo('paid'); // throws on invalid transition
Where to look first: the trait source (src/Traits/Statable.php or similar) — it is the entire public API surface. Read it before the README, because the README is thin and the method names (transitionTo, canTransitionTo, state, scopes) are what you will actually be calling. Also read tests/ to see the canonical happy path and the exact exception thrown on a rejected transition.
First use case: an order/payment/invoice lifecycle in a domain where an illegal jump must be impossible to write by accident. The value is not "storing an enum" — it is that pending -> shipped fails loudly.
Controller flow — validate, then transition:
public function update(Request $request, Order $order)
{
$request->validate(['state' => ['required', 'string']]);
if (! $order->canTransitionTo($request->state)) {
throw ValidationException::withMessages([
'state' => "Cannot move from {$order->state} to {$request->state}.",
]);
}
$order->transitionTo($request->state);
return back();
}
Checking first (rather than catching the exception) keeps the error a 422 instead of a 500 and puts the user-facing message in your control.
Wrap side effects in Laravel events, not in the controller. The trait's main integration win is that transition events let you decouple "the order got paid" from "send a receipt":
// app/Providers/EventServiceProvider.php
protected $listen = [
\Iben12\LaravelStatable\Events\StateChanged::class => [
\App\Listeners\SendPaidReceipt::class,
\App\Listeners\SyncToWarehouse::class,
],
];
Verify the actual event class name and its payload in the source — this is the single most important thing to check, because side effects attached to a non-firing event fail silently.
Query by state — use the provided scope and index the column:
Order::whereState('paid')->get();
Order::whereState(['pending', 'paid'])->get(); // if supported
Always ->index() the state column. Nearly every list screen and every scheduled job ("find all paid orders older than an hour") filters on it.
Guard rails at the model boundary. Put transitionTo() calls in a small domain service or model method rather than sprinkled through controllers:
class Order extends Model
{
public function markPaid(Payment $payment): void
{
$this->transitionTo('paid');
}
}
This gives you one place to add audit logging, an OrderPaid domain event, and a lock.
Audit trail: pair the transition event listener with a state_changes table (model_type, model_id, from, to, user_id, created_at). The package validates transitions; it does not record history, and you will be asked for history.
Form validation from the machine: expose allowed transitions so the frontend can render only legal buttons instead of a free-text state field:
$allowed = $order->transitionsFromCurrentState(); // check exact name in source
Last release 2021 — verify compatibility before committing. This package predates Laravel 9/10/11 conventions. Check: does it declare illuminate/support with a constraint that permits your Laravel version? Does it work on PHP 8.2+? If the constraint blocks install, fork it and loosen the requirement — the source is small enough that this is a 10-minute job, and it is safer than excluding the package from a composer update.
Do not mutate the state column directly. $order->update(['state' => 'paid']) bypasses validation and fires no transition events. If state is mass-assignable, someone will eventually do this. Add state to $guarded (or omit it from $fillable) so that the only path is transitionTo().
Undefined transitions are a common source of runtime exceptions. The $transitions array is authoritative. If a state exists in $states but has no key in $transitions (or vice versa), you get either an unreachable state or an exception on a legal-looking move. Keep both arrays adjacent in the file, and add a test that every state appears in $transitions.
Concurrency is not handled for you. Two requests can both read pending, both see ->paid as allowed, and both transition. Use a transaction with a lock for anything financial:
DB::transaction(function () use ($order) {
$order = Order::lockForUpdate()->find($order->id);
$order->transitionTo('paid');
});
Terminal states need explicit modelling. A state with no outgoing transitions (shipped in the example above) will throw on any attempt to move out — which is correct, but if you later need shipped -> returned, you must edit the machine and write a migration for any rows already sitting in the terminal state.
Events fire on transition, not on creation. If your model is created with state = 'pending', no transition event fires, so listeners that expect to handle "order entered pending" must also hook created. Don't rely on the state machine for initial-state side effects.
Test against the trait, not the database. Because the package is pure PHP around the trait, unit tests can instantiate the model without touching the DB at all — much faster than factories for validating transition matrices:
$this->expectException(\Iben12\LaravelStatable\Exceptions\InvalidTransition::class);
(new Order(['state' => 'pending']))->transitionTo('shipped');
Extension point: override the guard method rather than duplicating the matrix. If you need role-based transitions (only an admin may cancel a paid order), subclass or override the canTransitionTo logic in a base model instead of adding a second if ladder in every controller. That keeps one source of truth for "is this legal" and layers authorization on top via a Policy.
How can I help you explore Laravel packages today?