soylentgreenstudio/laravel-enum-states

A state machine library for Laravel using native PHP 8.1 Backed Enums as the single source of truth.

Maintainers

Package info

github.com/soylentgreenstudio/laravel-enum-states

pkg:composer/soylentgreenstudio/laravel-enum-states

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 5

Open Issues: 0

v1.3.0 2026-04-19 18:54 UTC

This package is auto-updated.

Last update: 2026-05-19 19:09:41 UTC


README

License: MIT Laravel PHP

A state machine library for Laravel using native PHP 8.1 Backed Enums as the single source of truth.

Declare states, transitions, guards, and hooks via PHP Attributes directly on your Enum — no separate state classes, no boilerplate.

Table of Contents

Quick Start

composer require soylentgreenstudio/laravel-enum-states
php artisan vendor:publish --tag=enum-states-migrations
php artisan migrate
// 1. Define your enum with attributes
enum OrderStatus: string
{
    #[InitialState]
    #[Transition(to: [self::Processing])]
    case Pending = 'pending';

    #[Transition(to: [self::Shipped])]
    case Processing = 'processing';

    #[FinalState]
    case Shipped = 'shipped';

    // Reachable from any non-final case — no need to list it on each source
    #[FinalState]
    #[TransitionFrom(from: '*')]
    case Cancelled = 'cancelled';
}

// 2. Add the trait to your model — that's it
class Order extends Model
{
    use HasStateMachines;

    protected $casts = [
        'status' => OrderStatus::class,
    ];
}

// 3. Transition states
$order->transitionTo(OrderStatus::Processing);
$order->transitionTo(OrderStatus::Shipped, ['tracking' => 'ABC123']);

// 4. Check if transition is allowed (never throws)
$order->canTransitionTo(OrderStatus::Cancelled); // bool

// 5. Query by state
Order::whereState('status', OrderStatus::Pending)->get();

// 6. View transition history
$order->stateHistory('status');

Features

Feature Description
Enum-driven States and transitions declared via PHP Attributes on Backed Enums
Zero config Trait auto-detects state machine fields from $casts — no registration needed
Reverse / wildcard transitions Declare inbound edges on the target state with #[TransitionFrom(from: '*')] or an explicit case list
Guards Control whether a transition is allowed via TransitionGuard contract
Multiple guards (AND) Pass guard: [GuardA::class, GuardB::class] — every guard in the array must pass
Hooks Run logic before/after transition via TransitionHook contract
Transition history Every transition recorded with metadata in a configurable history table
Configurable table name Override the history table via config/enum-states.php or the ENUM_STATES_TABLE env var
Query scopes whereState, whereNotState, whereStateIn — filter models by state
Events TransitionStarted, TransitionCompleted, TransitionFailed fired automatically
Multiple state machines One model can have multiple state fields, each independent
DB transactions Transitions wrapped in DB::transaction() with pessimistic locking — hooks and state update are atomic
Initial / Final states Mark states with #[InitialState] and #[FinalState] attributes
Metadata Pass arbitrary data with each transition — stored in history as JSON
Async hooks Dispatch hooks to queues via AsyncTransitionHook — fire-and-forget before, post-commit after
Artisan commands enum-states:graph, make:enum-state, make:transition-guard for visualization and scaffolding
Container resolution Guards and hooks are resolved via Laravel's service container — inject dependencies freely

Installation

Requirements

  • PHP 8.1+
  • Laravel 10.x — 12.x

Install

composer require soylentgreenstudio/laravel-enum-states

Publish the migration

php artisan vendor:publish --tag=enum-states-migrations
php artisan migrate

This copies create_state_transitions_table.php.stub to your application's database/migrations/ with a fresh timestamp and creates the history table (default name: state_transitions).

Publish the config (optional)

Only needed if you want to rename the history table or override defaults:

php artisan vendor:publish --tag=enum-states-config

This creates config/enum-states.php in your application.

Configuration

The package ships with a single config file, config/enum-states.php:

return [
    'table' => env('ENUM_STATES_TABLE', 'state_transitions'),
];

Customizing the history table name

If state_transitions conflicts with an existing table or doesn't fit your naming convention, override it via .env:

ENUM_STATES_TABLE=audit_state_transitions

Or publish the config and edit directly:

// config/enum-states.php
'table' => 'audit_state_transitions',

Important: set this value before running php artisan migrate — the migration reads the config value at runtime, and the StateTransition model resolves its table name on construction.

Architecture

Transition lifecycle

$order->transitionTo(OrderStatus::Processing, $metadata)
  └─ StateMachineManager::transition()
      ├─ 1. Check current state is not #[FinalState]   → FinalStateException
      ├─ 2. Find a matching #[Transition] edge          → InvalidTransitionException
      ├─ 3. Resolve guard(s) and verify all allow       → InvalidTransitionException
      ├─ 4. Fire TransitionStarted event
      └─ 5. DB::transaction() with lockForUpdate()
            ├─ Re-read and re-validate under the lock
            ├─ Run `before` hook (sync)
            ├─ Update model field + save
            ├─ Write record to history table
            └─ Run `after` hook (sync; async collected for post-commit)
      ├─ 6. Dispatch any async after-hooks (post-commit)
      ├─ 7. Fire TransitionCompleted event
      └─ On exception: Fire TransitionFailed event, re-throw

How auto-detection works

The HasStateMachines trait inspects the model's $casts array on first access:

  1. For each cast that points to a BackedEnum class
  2. Check if any case on that enum has #[Transition], #[TransitionFrom], #[InitialState], or #[FinalState] attributes
  3. Register those fields as managed state machines

No manual field registration required.

Database schema

The history table (default name state_transitions, override via config('enum-states.table')) stores full transition history:

Column Type Description
id bigint Primary key
model_type string Morphable model class
model_id bigint Morphable model ID
field string Field name (e.g. status)
from string Previous state value
to string New state value
metadata json, nullable Arbitrary data passed with the transition
transitioned_at timestamp When the transition occurred
created_at timestamp Record creation time

Enum Definition

Define your states as a PHP 8.1 Backed Enum. Use attributes to declare the state machine behavior:

use SoylentGreenStudio\EnumStates\Attributes\InitialState;
use SoylentGreenStudio\EnumStates\Attributes\FinalState;
use SoylentGreenStudio\EnumStates\Attributes\Transition;
use SoylentGreenStudio\EnumStates\Attributes\TransitionFrom;

enum OrderStatus: string
{
    #[InitialState]
    #[Transition(
        to: [self::Processing],
        guard: HasItemsInStock::class,
        after: SendOrderConfirmation::class,
    )]
    case Pending = 'pending';

    #[Transition(
        to: [self::Shipped],
        before: ValidateShippingAddress::class,
    )]
    case Processing = 'processing';

    #[FinalState]
    case Shipped = 'shipped';

    // Reverse-declared: reachable from any non-final case
    #[FinalState]
    #[TransitionFrom(from: '*')]
    case Cancelled = 'cancelled';
}

Attribute reference

Attribute Target Description
#[InitialState] Enum case Marks the default/starting state
#[FinalState] Enum case Marks a terminal state — no transitions allowed from it
#[Transition] Enum case (repeatable) Declares outbound transitions from this state
#[TransitionFrom] Enum case (repeatable) Declares inbound transitions to this state from the given sources

Transition attribute parameters

Parameter Type Default Description
to array required Array of enum cases this state can transition to
guard string|array|null null FQCN of a TransitionGuard, or an array of FQCNs (AND-combined)
before ?string null FQCN of a TransitionHook or AsyncTransitionHook — runs before persisting
after ?string null FQCN of a TransitionHook or AsyncTransitionHook — runs after persisting

The #[Transition] attribute is repeatable — you can stack multiple transitions on one case (OR semantics across attributes):

#[Transition(to: [self::Approved], guard: ManagerApproval::class)]
#[Transition(to: [self::Rejected], guard: CanReject::class)]
case Pending = 'pending';

Model Setup

Add the HasStateMachines trait and cast your state fields to the enum:

use SoylentGreenStudio\EnumStates\Traits\HasStateMachines;

class Order extends Model
{
    use HasStateMachines;

    protected $casts = [
        'status'         => OrderStatus::class,
        'payment_status' => PaymentStatus::class,
    ];
}

That's it. The trait auto-detects which cast fields are Backed Enums with state machine attributes. No getStateMachineFields() method needed.

Transitioning States

Basic transition

$order->transitionTo(OrderStatus::Processing);

With metadata

Metadata is stored in the transition history record:

$order->transitionTo(OrderStatus::Processing, [
    'reason'  => 'Payment confirmed',
    'user_id' => auth()->id(),
]);

Explicit field name

When a model has multiple state machines, specify the field:

$order->transitionTo('payment_status', PaymentStatus::Paid, $metadata);

Check before transitioning

Returns bool, never throws:

if ($order->canTransitionTo(OrderStatus::Shipped)) {
    $order->transitionTo(OrderStatus::Shipped);
}

Exception handling

Exception When
FinalStateException Transitioning from a state marked #[FinalState]
InvalidTransitionException No #[Transition]/#[TransitionFrom] allows the requested state change
InvalidTransitionException All guards for the matching transitions returned false — message lists the guard class names

Reverse / Wildcard Transitions

Sometimes a state is reachable from many source states. Rather than duplicate #[Transition(to: [self::Cancelled])] on every source case, declare the inbound direction on the target case with #[TransitionFrom].

Wildcard: reachable from any non-final state

Use the '*' sentinel to make the target reachable from every non-final case (excluding the target itself and any #[FinalState] case):

enum OrderStatus: string
{
    #[InitialState]
    #[Transition(to: [self::Processing])]
    case Pending = 'pending';

    #[Transition(to: [self::Shipped])]
    case Processing = 'processing';

    #[FinalState]
    case Shipped = 'shipped';

    // Cancelled is reachable from Pending and Processing —
    // without touching those cases' own attribute lists.
    #[FinalState]
    #[TransitionFrom(from: '*')]
    case Cancelled = 'cancelled';
}

Explicit source list

Pass an array of enum cases (of the same enum) to enumerate allowed sources:

enum DocumentStatus: string
{
    #[InitialState]
    case Draft = 'draft';

    case Review = 'review';
    case Approved = 'approved';

    // Archived from Draft or Review — but NOT from Approved
    #[FinalState]
    #[TransitionFrom(from: [self::Draft, self::Review])]
    case Archived = 'archived';
}

With guards and hooks

#[TransitionFrom] accepts the same guard, before, and after parameters as #[Transition]:

#[FinalState]
#[TransitionFrom(
    from: '*',
    guard: [IsAdmin::class, HasCloseReason::class],
    after: NotifyClosureWebhook::class,
)]
case Closed = 'closed';

Wildcard semantics

  • from: '*' expands to every non-final case of the enum except the target itself. No self-loop is created, and final states are never included as sources.
  • The expansion is resolved once per enum class and cached — there is no runtime overhead compared to hand-written #[Transition] attributes.
  • Reverse edges are visible in enum-states:graph output just like forward transitions.

Mixing forward and reverse on the same edge

Forward #[Transition] on a source case and reverse #[TransitionFrom] on the target case may cover the same edge. Both contribute separate Transition objects to the source case; OR semantics between them means the transition is permitted if either attribute allows it:

enum Status: string
{
    #[InitialState]
    #[Transition(to: [self::Done], guard: FastPathGuard::class)]
    case Pending = 'pending';

    #[TransitionFrom(from: [self::Pending], guard: FallbackGuard::class)]
    case Done = 'done';
}

TransitionFrom attribute parameters

Parameter Type Default Description
from string|array required '*' for all non-final cases (excluding the target), or an array of BackedEnum cases of the same enum
guard string|array|null null Single guard or AND-combined array
before ?string null Before-hook FQCN
after ?string null After-hook FQCN

Guards

Guards control whether a transition is allowed. Implement the TransitionGuard contract:

use SoylentGreenStudio\EnumStates\Contracts\TransitionGuard;

class HasItemsInStock implements TransitionGuard
{
    public function allow(Model $model, array $metadata): bool
    {
        return $model->items()->where('in_stock', true)->exists();
    }
}

Guards are resolved via the Laravel service container — you can inject any dependencies via the constructor.

If a guard returns false, transitionTo() throws InvalidTransitionException.

Guard with dependency injection

class HasSufficientBalance implements TransitionGuard
{
    public function __construct(
        private PaymentGateway $gateway,
    ) {}

    public function allow(Model $model, array $metadata): bool
    {
        return $this->gateway->getBalance($model->user_id) >= $model->total;
    }
}

Multiple guards per transition (AND)

Pass an array of guard classes to require all of them to return true:

#[Transition(
    to: [self::Approved],
    guard: [IsAdmin::class, HasApprovalPermission::class, BudgetAvailable::class],
)]
case Pending = 'pending';

Semantics:

  • Every guard in the array must return true for the transition to be allowed.
  • Guards are evaluated in array order. The first false short-circuits the rest.
  • On failure, InvalidTransitionException lists every guard class name that was checked.

A single string guard continues to work unchanged — passing guard: IsAdmin::class is equivalent to guard: [IsAdmin::class].

AND vs OR

Goal Syntax
All guards must pass (AND) One #[Transition] with guard: [A::class, B::class]
Any guard may pass (OR) Multiple stacked #[Transition] attributes with different guards

Example — admin can approve directly, or a manager with budget approval can approve:

#[Transition(to: [self::Approved], guard: IsAdmin::class)]
#[Transition(to: [self::Approved], guard: [IsManager::class, BudgetAvailable::class])]
case Pending = 'pending';

Hooks

Hooks run logic before or after a transition. Implement the TransitionHook contract:

use SoylentGreenStudio\EnumStates\Contracts\TransitionHook;

class SendOrderConfirmation implements TransitionHook
{
    public function handle(Model $model, mixed $from, mixed $to, array $metadata): void
    {
        Mail::to($model->user)->send(new OrderConfirmed($model));
    }
}

Before vs After

Type When it runs On exception
before Before model is saved, inside DB transaction Transition is rolled back
after After model is saved, inside same DB transaction Transition is rolled back

Both hooks receive the model, the $from and $to enum cases, and the metadata array.

Hook with dependency injection

class NotifySlack implements TransitionHook
{
    public function __construct(
        private SlackClient $slack,
    ) {}

    public function handle(Model $model, mixed $from, mixed $to, array $metadata): void
    {
        $this->slack->send("Order #{$model->id} changed from {$from->name} to {$to->name}");
    }
}

Transition History

Every transition is recorded in the history table (default state_transitions, configurable):

// History for a specific field
$order->stateHistory('status');
// => Collection of StateTransition models

// History for all state machine fields
$order->stateHistory();

Each StateTransition record contains:

$transition->from;              // 'pending'
$transition->to;                // 'processing'
$transition->field;             // 'status'
$transition->metadata;          // ['reason' => 'Payment confirmed'] or null
$transition->transitioned_at;   // Carbon instance
$transition->created_at;        // Carbon instance

Query Scopes

The HasStateMachines trait adds query scopes for filtering models by state:

// Exact match
Order::whereState('status', OrderStatus::Pending)->get();

// Exclude a state
Order::whereNotState('status', OrderStatus::Cancelled)->get();

// Match multiple states
Order::whereStateIn('status', [
    OrderStatus::Pending,
    OrderStatus::Processing,
])->get();

Events

Three events are fired automatically during each transition:

Event When Payload
TransitionStarted Before DB transaction begins $model, $field, $from, $to, $metadata
TransitionCompleted After DB transaction commits $model, $field, $from, $to, $metadata
TransitionFailed On any exception (then re-thrown) $model, $field, $from, $to, $exception

Listening to events

// In EventServiceProvider or via Event::listen()
use SoylentGreenStudio\EnumStates\Events\TransitionCompleted;

Event::listen(TransitionCompleted::class, function (TransitionCompleted $event) {
    Log::info("Order #{$event->model->id}: {$event->field} changed", [
        'from' => $event->from->value,
        'to'   => $event->to->value,
        'meta' => $event->metadata,
    ]);
});

Artisan Commands

Visualize State Graph

php artisan enum-states:graph "App\Enums\OrderStatus"

Output:

OrderStatus State Graph
========================
[Initial] Pending
  → Processing (guard: HasItemsInStock)
  → Cancelled
Processing
  → Shipped (before: ValidateShippingAddress)
  → Cancelled
[Final] Shipped
[Final] Cancelled

When an array of guards is used, they are joined with + in the output (e.g. guard: IsAdmin+HasBudget) to signal AND-combination.

Virtual edges contributed by #[TransitionFrom] are rendered alongside forward transitions — no special flag needed.

Generate a Mermaid diagram for documentation:

php artisan enum-states:graph "App\Enums\OrderStatus" --format=mermaid

Output:

stateDiagram-v2
    [*] --> Pending
    Pending --> Processing : guard: HasItemsInStock
    Pending --> Cancelled
    Processing --> Shipped : before: ValidateShippingAddress
    Processing --> Cancelled
    Shipped --> [*]
    Cancelled --> [*]

Generate Enum State

Scaffold a new enum with state machine attributes:

php artisan make:enum-state OrderStatus

Creates app/Enums/OrderStatus.php with #[InitialState], #[FinalState], and #[Transition] boilerplate.

Generate Transition Guard

Scaffold a new guard class:

php artisan make:transition-guard HasItemsInStock

Creates app/Guards/HasItemsInStock.php implementing TransitionGuard.

Async Hooks

For hooks that don't need to block the transition, implement the AsyncTransitionHook contract. Async hooks are dispatched as queued jobs instead of running synchronously.

use SoylentGreenStudio\EnumStates\Contracts\AsyncTransitionHook;

class SendShipmentNotification implements AsyncTransitionHook
{
    public function handle(Model $model, mixed $from, mixed $to, array $metadata): void
    {
        Mail::to($model->user)->send(new OrderShipped($model));
    }

    public function queue(): ?string
    {
        return 'notifications'; // or null for default queue
    }
}

Use it the same way as synchronous hooks in the #[Transition] attribute:

#[Transition(
    to: [self::Shipped],
    before: ValidateShippingAddress::class,    // sync — runs inside transaction
    after: SendShipmentNotification::class,     // async — dispatched to queue
)]
case Processing = 'processing';

Behavior

Hook type TransitionHook (sync) AsyncTransitionHook (async)
before Runs inside DB transaction, blocks transition Fire-and-forget: dispatched to queue, does not block
after Runs inside DB transaction, can roll back Dispatched after successful commit
  • Sync hooks continue to work exactly as before (full backward compatibility)
  • Async before hooks are dispatched to the queue at the before-hook point but do not block the transition
  • Async after hooks are dispatched only after the DB transaction commits successfully
  • The internal ProcessTransitionHook job wraps the async hook execution

AsyncTransitionHook contract

interface AsyncTransitionHook
{
    public function handle(Model $model, mixed $from, mixed $to, array $metadata): void;
    public function queue(): ?string; // queue name or null for default
}

Testing

composer test

The package uses Pest + Orchestra Testbench.

Test coverage

Suite Covers
TransitionTest Happy path transitions, disallowed transitions, final state enforcement, auto-detection
GuardTest Guard blocking, guard allowing, canTransitionTo with guards, double-guard prevention
MultiGuardTest AND-combined guard arrays, short-circuit on first false, exception message content, backward compat with single string guard
WildcardTransitionTest #[TransitionFrom] wildcard + explicit list, final-state and self-loop exclusion, merged forward/reverse edges, guards on reverse, graph rendering
ConfigTableTest Default table name, config override at construction, migration against default name
HookTest Before/after hook execution order, rollback on hook exception
HistoryTest History recording, metadata storage, multi-field independence
ScopeTest whereState, whereNotState, whereStateIn
EventTest TransitionStarted, TransitionCompleted, metadata in events
AsyncHookTest Async hook dispatch, named queues, post-commit dispatch, backward compatibility
CommandTest Graph command (text/mermaid), generator commands, error handling
EdgeCaseTest Multiple #[Transition] OR-semantics, duplicate enum on fields, initial+final on same case, plain model
ValidationTest Guards/hooks not implementing contracts, reflection cache invalidation, descriptive error messages

Comparison with Alternatives

vs. spatie/laravel-model-states

Aspect spatie/laravel-model-states laravel-enum-states
State definition Separate PHP classes per state Native PHP Backed Enum cases
Transitions Separate Transition classes or $transitions array #[Transition] / #[TransitionFrom] attributes on enum cases
Configuration $states config in model + state classes $casts only — auto-detected from enum attributes
Guards Inside transition classes or canTransitionTo() method Dedicated TransitionGuard contract, container-resolved, AND-combined arrays
Reverse / wildcard Manual duplication per source #[TransitionFrom(from: '*')] on the target
Hooks Transition class handle() + events before/after hooks on the attribute, sync or async
History Via separate package or custom Built-in, configurable table name
Multiple fields Supported, requires explicit config Supported, auto-detected from $casts
Boilerplate 1 class per state + 1 class per transition 1 enum + attributes only
PHP version PHP 8.0+ PHP 8.1+ (requires Backed Enums)

Advantages of laravel-enum-states:

  • Zero boilerplate — no separate state/transition classes
  • Everything declared in one place — the Enum itself
  • Native PHP Enums for type safety — IDE autocomplete, exhaustive match
  • Reverse/wildcard transitions and multi-guard AND out of the box
  • Built-in transition history with metadata, configurable table name
  • Guards and hooks resolved via service container

Disadvantages compared to spatie:

  • Requires PHP 8.1+ (Backed Enums)
  • No custom transition logic classes — hooks are simpler but less flexible
  • Smaller community and ecosystem
  • No default state configuration on the model

vs. asantibanez/laravel-eloquent-state-machines

Aspect asantibanez laravel-enum-states
State definition StateMachine class with $initialState and $transitions Backed Enum with #[Transition] attributes
Configuration $stateMachines array in model Auto-detected from $casts
History Built-in Built-in, configurable table name
Guards beforeTransitionHook() in StateMachine class Dedicated TransitionGuard contract, AND-combined arrays
Type safety String-based states Enum-based — IDE autocomplete, type checking

Summary: When to use laravel-enum-states

Choose laravel-enum-states when:

  • You want states defined as native PHP Enums with full type safety
  • You prefer zero-config auto-detection over manual registration
  • You want guards and hooks as separate, testable, injectable classes
  • You need built-in transition history with metadata and a configurable table name
  • You want reverse/wildcard transitions without hand-written duplication
  • You want everything declared in one place — the Enum

Choose alternatives when:

  • You need complex transition logic in dedicated classes
  • You need PHP 8.0 compatibility
  • You need a larger community and ecosystem
  • You prefer explicit configuration over convention

API Reference

Attributes

Attribute Target Parameters
#[InitialState] Enum case
#[FinalState] Enum case
#[Transition] Enum case (repeatable) to: array, guard: string|array|null, before: ?string, after: ?string
#[TransitionFrom] Enum case (repeatable) from: string|array, guard: string|array|null, before: ?string, after: ?string

Contracts

Interface Method
TransitionGuard allow(Model $model, array $metadata): bool
TransitionHook handle(Model $model, mixed $from, mixed $to, array $metadata): void
AsyncTransitionHook handle(...) + queue(): ?string — dispatched as queued job

HasStateMachines trait

Method Returns Description
transitionTo($state, $metadata) void Transition to a new state
transitionTo($field, $state, $metadata) void Transition with explicit field name
canTransitionTo($state, $metadata) bool Check if transition is allowed
stateHistory($field) Collection Get transition history for a field
stateHistory() Collection Get transition history for all fields
getStateMachineFields() array Get detected state machine fields

Query scopes

Scope Signature
whereState whereState(string $field, BackedEnum $state)
whereNotState whereNotState(string $field, BackedEnum $state)
whereStateIn whereStateIn(string $field, array $states)

Events

Event Properties
TransitionStarted Model $model, string $field, mixed $from, mixed $to, array $metadata
TransitionCompleted Model $model, string $field, mixed $from, mixed $to, array $metadata
TransitionFailed Model $model, string $field, mixed $from, mixed $to, Throwable $exception

Exceptions

Exception When thrown
FinalStateException Attempting to transition from a #[FinalState]
InvalidTransitionException No matching #[Transition]/#[TransitionFrom] found, or every matching transition was blocked by its guard(s)

Configuration

Key Default Description
enum-states.table state_transitions (overridable via ENUM_STATES_TABLE env) Name of the history table

Publishable assets

Tag Publishes
enum-states-migrations The migration stub as a timestamped migration in database/migrations/
enum-states-config config/enum-states.php into the application's config directory

Models

Class Table Description
StateTransition config('enum-states.table') (default state_transitions) Polymorphic history record with from, to, field, metadata, transitioned_at

License

MIT License. See LICENSE.md for details.