innoge/laravel-enum-states
A state machine for Eloquent built on native PHP enums. Define transitions, defaults, and lifecycle actions on the enum itself, register fields with a single attribute, and keep your normal model casts. Invalid transitions throw; defaults apply automatically.
This package replaces the "string status + if/else" pattern with a native enum, and puts the transition graph on that enum. There is nothing to configure, publish, or migrate — the whole integration is one enum and one attribute.
Install
composer require innoge/laravel-enum-states
Define the enum. States are ordinary cases; the legal moves are declared once, on the enum itself:
namespace App\Enums;
use InnoGE\LaravelEnumStates\Contracts\StatefulEnum;
enum OrderStatus: string implements StatefulEnum
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function transitions(): array
{
return [
self::Pending->value => [self::Paid, self::Cancelled],
self::Paid->value => [self::Shipped, self::Cancelled],
self::Shipped->value => [],
self::Cancelled->value => [],
];
}
}
Register the field on the model with the attribute, and keep the normal Eloquent cast — the package does not replace casting:
use InnoGE\LaravelEnumStates\Attributes\State;
class Order extends Model
{
#[State('status')]
protected $casts = [
'status' => OrderStatus::class,
];
}
Where to look first: src/Attributes/State.php and the enum contract. It is a small package — read the attribute and the contract before writing feature code. Confirm the exact contract method name and whether the attribute takes the field name as an argument or infers it from $casts; after those two facts, everything else is obvious.
First use case: a status column on orders that is currently a free string. Swap it to OrderStatus, add #[State('status')], and replace every $order->status = 'paid' with $order->transitionTo(OrderStatus::Paid). You get illegal-transition protection for free and can delete the ad-hoc if ($order->status === ...) checks in controllers.
Validate the target, then transition in the controller.
class UpdateOrderStatusRequest extends FormRequest
{
public function rules(): array
{
return ['status' => ['required', Rule::enum(OrderStatus::class)]];
}
}
public function update(UpdateOrderStatusRequest $request, Order $order)
{
$target = OrderStatus::from($request->validated('status'));
abort_unless($order->canTransitionTo($target), 422, 'Invalid status transition');
$order->transitionTo($target);
return back();
}
Rule::enum handles "is this a real case"; canTransitionTo handles "is this a legal move from here". Keep both — they fail for different reasons and give better error messages.
Wrap transitions in a transaction with a row lock. Two webhooks can race Pending → Paid and Pending → Cancelled. The graph check reads the current state from the model instance, so you must re-read it under lock:
DB::transaction(function () use ($order, $target) {
$order->lockForUpdate()->refresh();
$order->transitionTo($target);
});
Filter and report with plain Eloquent. Because the state is just an enum-cast column, there is no join and no extra table:
Order::where('status', OrderStatus::Paid)->count();
Order::whereIn('status', [OrderStatus::Paid, OrderStatus::Shipped])->get();
Add small helpers on the enum (label(), color(), isFinal()) for Blade and API resources — the enum is a natural home for presentation metadata.
Keep business rules out of the graph. The enum should answer "which states can I move to". Anything that depends on the user, the amount, or the day of the week belongs in a policy or an action class that calls canTransitionTo before transitionTo.
Multiple state fields on one model are fine — one #[State] per property (e.g. payment_status and fulfillment_status), each with its own enum. Do not try to model orthogonal concerns as one giant enum; the transition arrays become unreadable.
Audit trail if you need one. "No extra tables" is a feature until someone asks "who cancelled this order?". If history matters, listen to the package's transition event (or fire your own inside the action class) and write to a state_transitions table yourself. Do not expect the package to do this.
Factories and tests. Set state with the enum directly in factories ('status' => OrderStatus::Pending). For transition tests, prefer $model->transitionTo(...) assertions over raw update() so you exercise the guard:
expect(fn () => $order->transitionTo(OrderStatus::Shipped))
->toThrow(InvalidTransition::class);
Exhaustiveness test — write this one on day one:
it('defines transitions for every state', function () {
foreach (OrderStatus::cases() as $case) {
expect($orderStatus->transitions())->toHaveKey($case->value);
}
});
The graph is an untyped array; this test is the only thing that will catch a forgetting-to-add-a-state bug when the enum grows.
$order->update(['status' => 'shipped']) and $order->status = OrderStatus::Shipped; $order->save() are rejected (or aren't). If the package only guards transitionTo(), then direct writes, fill(), factories, seeders, and raw query()->update() are all silent escape hatches. Decide consciously, and add a saving observer or DB check constraint if you need belt-and-braces.$casts entry is not optional. The attribute registers the field with the state machine; the cast is what turns the column into an enum instance. Both are required and easy to forget when copying a model.fillable is not a guard. Marking status guarded helps with mass-assignment hygiene but does nothing against $model->status = .... Treat them as separate concerns.status can be null, canTransitionTo on a fresh model has no current state. Handle the null case explicitly (map it to an initial state, or guard with abort_if($order->status === null)), otherwise you get confusing "invalid transition" responses on first save.'complete' → 'shipped') in a migration before switching the cast, or Eloquent will throw ValueError when reading rows your enum can't represent. Run whereNotIn('status', $enum->values()) first to find stragglers.string-backed enum. Pure enums will not round-trip through the database.composer.json, read the source once before relying on an edge behaviour, and keep your own tests around the transitions that matter. That way a patch release can't quietly change what "legal" means in your domain.canBeCancelledBy(User $u) to the enum case. Resist — it drags auth and HTTP context into a value object and makes the graph untestable in isolation. Enum = graph + labels; services and policies = the rest.How can I help you explore Laravel packages today?