orobogenius/laravel-model-statable
Add state and status management to any Eloquent model. Apply the trait to define allowed statuses, transition between them, query by state with scopes, and optionally track status history, all through a simple, expressive Laravel API.
This is the first public release (1.0.0), so there is no upgrade/migration path from an earlier version. Start with the README’s “preparing your model”, “defining states”, and “relationships” sections, then check the package tests/examples for the exact API shape.
Minimal adoption path:
Typical shape to verify against the docs:
class Order extends Model
{
use HasStates;
protected $casts = [
'state' => OrderState::class,
];
}
First practical use case: an order lifecycle such as Pending -> Paid -> Shipped -> Cancelled. Put the transition behind a service/action rather than directly in a controller, so validation, authorization, and side effects stay together.
Also use it for relationship filtering early: e.g. “customers with at least one paid order” or “only active subscriptions”, rather than loading all related models and filtering in PHP.
Centralize the state machine. Define allowed transitions in the state classes or a dedicated transition map. Avoid scattering if ($model->state === ...) checks across controllers, jobs, and listeners.
Use action/service classes for transitions. A transition often triggers emails, invoices, inventory changes, or audit records. Keep that workflow in one place:
DB::transaction(function () use ($order) {
$order->state->transitionTo(Paid::class);
// side effects...
});
Verify whether your package persists the state automatically after transitionTo(); if not, call save() explicitly in the action.
Validate before applying. In form requests or actions, check whether the requested transition is allowed from the current state. Return a validation error or 409 Conflict rather than letting an invalid transition throw deep in the stack.
Drive UI actions from the state machine. Expose “available transitions” to the frontend so buttons/actions match what the backend will allow. This keeps admin panels and APIs consistent.
Filter relationships at the query level. Use the package’s state scope on relationship queries rather than filtering collections:
$paidOrders = Order::whereState('state', Paid::class)->get();
$customers = Customer::whereHas('orders', function ($query) {
$query->whereState('state', Paid::class);
})->get();
For eager loading, constrain the relationship instead of loading everything:
$customers = Customer::with(['orders' => function ($query) {
$query->whereState('state', Paid::class);
}])->get();
Use factories and tests for state coverage. Add factory states for common statuses and test:
Keep authorization separate. State transitions are business rules, not a replacement for policies. A user may be allowed to view an order but not transition it to Shipped.
Initial state and existing rows. Set both a database default and a model-level default if the package supports it. For existing tables, backfill the state column before making it non-nullable.
Confirm persistence behavior. Some state packages persist on transition; others only mutate the attribute. Check the README/tests and write a test that reloads the model from the database after a transition.
Race conditions matter. Two requests can try to transition the same model at once. Use a transaction plus row locking, or an atomic conditional update, when the transition has real consequences.
Do not mass-assign state from request input. Treat state as a guarded attribute or only change it through transition methods. Otherwise a user can bypass the state machine with a crafted payload.
Relationship filters are query constraints, not collection filters. whereHas() and constrained with() are the right tools. Filtering a loaded collection with ->filter() hides N+1 and pagination problems.
Index state columns used in relationship filters. If you frequently query “active” or “paid” related records, add indexes appropriate to your database and query patterns.
Watch serialization. A state cast may serialize as an object or class name depending on the package. Use API resources to expose a stable string/enum value to clients.
Be careful with raw database values. When debugging, check the actual stored value and confirm it maps to the expected state class. A typo in a state class or database value can look like a broken transition.
First-release caution. Pin the version in composer.json and review future changelogs closely. For a 1.0.0 package, validate the API against your Laravel version and test the specific workflow you depend on before rolling it out broadly.
Extension points to look for: custom state classes, transition hooks/listeners, query scopes, custom casts, and policy integration. These are the places to add audit logging, notifications, and authorization without forking the package.
How can I help you explore Laravel packages today?