Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Enum States Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • The attribute is the enforcement point — verify what it actually blocks. Test explicitly that $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.
  • The $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.
  • Nullable columns. If 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.
  • Backfilling is on you. Rename legacy values ('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.
  • Unbacked enums will bite you. Because the state is stored in the column, use a string-backed enum. Pure enums will not round-trip through the database.
  • This is a young, single-maintainer package (5 stars, no dependents). Pin the version in 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.
  • Do not overload the enum. It is tempting to add 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log