PHPackages                             soylentgreenstudio/laravel-enum-states - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. soylentgreenstudio/laravel-enum-states

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

soylentgreenstudio/laravel-enum-states
======================================

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

v1.3.0(2mo ago)55↓91.7%MITPHPPHP ^8.1

Since Mar 16Pushed 2mo agoCompare

[ Source](https://github.com/soylentgreenstudio/laravel-enum-states)[ Packagist](https://packagist.org/packages/soylentgreenstudio/laravel-enum-states)[ RSS](/packages/soylentgreenstudio-laravel-enum-states/feed)WikiDiscussions main Synced 3w ago

READMEChangelogDependencies (8)Versions (8)Used By (0)

soylentgreenstudio/laravel-enum-states
======================================

[](#soylentgreenstudiolaravel-enum-states)

[![License: MIT](https://camo.githubusercontent.com/08cef40a9105b6526ca22088bc514fbfdbc9aac1ddbf8d4e6c750e3a88a44dca/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d626c75652e737667)](LICENSE.md)[![Laravel](https://camo.githubusercontent.com/8d05621a93ebe2a005d07b66cf72e6bd6af781566fb702daba60710d39c9c0a9/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c61726176656c2d31302e782d2d31322e782d7265642e737667)](https://laravel.com)[![PHP](https://camo.githubusercontent.com/fb7c72456e13f7d5ecf8486e29d02a2e6775aaf4d18622a63529976b0ed0740e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048502d382e312532422d707572706c652e737667)](https://www.php.net)

**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
-----------------

[](#table-of-contents)

- [Quick Start](#quick-start)
- [Features](#features)
- [Installation](#installation)
- [Configuration](#configuration)
- [Architecture](#architecture)
- [Enum Definition](#enum-definition)
- [Model Setup](#model-setup)
- [Transitioning States](#transitioning-states)
- [Reverse / Wildcard Transitions](#reverse--wildcard-transitions)
- [Guards](#guards)
- [Hooks](#hooks)
- [Transition History](#transition-history)
- [Query Scopes](#query-scopes)
- [Events](#events)
- [Artisan Commands](#artisan-commands)
- [Async Hooks](#async-hooks)
- [Testing](#testing)
- [Comparison with Alternatives](#comparison-with-alternatives)
- [API Reference](#api-reference)
- [License](#license)

Quick Start
-----------

[](#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
--------

[](#features)

FeatureDescription**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 freelyInstallation
------------

[](#installation)

### Requirements

[](#requirements)

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

### Install

[](#install)

```
composer require soylentgreenstudio/laravel-enum-states
```

### Publish the migration

[](#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)

[](#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
-------------

[](#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

[](#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
------------

[](#architecture)

### Transition lifecycle

[](#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

[](#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

[](#database-schema)

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

ColumnTypeDescription`id`bigintPrimary key`model_type`stringMorphable model class`model_id`bigintMorphable model ID`field`stringField name (e.g. `status`)`from`stringPrevious state value`to`stringNew state value`metadata`json, nullableArbitrary data passed with the transition`transitioned_at`timestampWhen the transition occurred`created_at`timestampRecord creation timeEnum Definition
---------------

[](#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-reference)

AttributeTargetDescription`#[InitialState]`Enum caseMarks the default/starting state`#[FinalState]`Enum caseMarks 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

[](#transition-attribute-parameters)

ParameterTypeDefaultDescription`to``array`requiredArray 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 persistingThe `#[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
-----------

[](#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
--------------------

[](#transitioning-states)

### Basic transition

[](#basic-transition)

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

### With metadata

[](#with-metadata)

Metadata is stored in the transition history record:

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

### Explicit field name

[](#explicit-field-name)

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

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

### Check before transitioning

[](#check-before-transitioning)

Returns `bool`, never throws:

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

### Exception handling

[](#exception-handling)

ExceptionWhen`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 namesReverse / Wildcard Transitions
------------------------------

[](#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

[](#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

[](#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

[](#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

[](#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

[](#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

[](#transitionfrom-attribute-parameters)

ParameterTypeDefaultDescription`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 FQCNGuards
------

[](#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

[](#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)

[](#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

[](#and-vs-or)

GoalSyntax**All** guards must pass (AND)One `#[Transition]` with `guard: [A::class, B::class]`**Any** guard may pass (OR)Multiple stacked `#[Transition]` attributes with different guardsExample — 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)

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

[](#before-vs-after)

TypeWhen it runsOn exception`before`Before model is saved, inside DB transactionTransition is rolled back`after`After model is saved, inside same DB transactionTransition is rolled backBoth hooks receive the model, the `$from` and `$to` enum cases, and the metadata array.

### Hook with dependency injection

[](#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
------------------

[](#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
------------

[](#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
------

[](#events)

Three events are fired automatically during each transition:

EventWhenPayload`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

[](#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
----------------

[](#artisan-commands)

### Visualize State Graph

[](#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

[](#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

[](#generate-transition-guard)

Scaffold a new guard class:

```
php artisan make:transition-guard HasItemsInStock
```

Creates `app/Guards/HasItemsInStock.php` implementing `TransitionGuard`.

Async Hooks
-----------

[](#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

[](#behavior)

Hook type`TransitionHook` (sync)`AsyncTransitionHook` (async)`before`Runs inside DB transaction, blocks transitionFire-and-forget: dispatched to queue, does not block`after`Runs inside DB transaction, can roll backDispatched 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

[](#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
-------

[](#testing)

```
composer test
```

The package uses [Pest](https://pestphp.com/) + [Orchestra Testbench](https://github.com/orchestral/testbench).

### Test coverage

[](#test-coverage)

SuiteCovers`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 messagesComparison with Alternatives
----------------------------

[](#comparison-with-alternatives)

### vs. spatie/laravel-model-states

[](#vs-spatielaravel-model-states)

Aspectspatie/laravel-model-stateslaravel-enum-states**State definition**Separate PHP classes per stateNative 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()` methodDedicated `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 customBuilt-in, configurable table name**Multiple fields**Supported, requires explicit configSupported, auto-detected from `$casts`**Boilerplate**1 class per state + 1 class per transition1 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

[](#vs-asantibanezlaravel-eloquent-state-machines)

Aspectasantibanezlaravel-enum-states**State definition**`StateMachine` class with `$initialState` and `$transitions`Backed Enum with `#[Transition]` attributes**Configuration**`$stateMachines` array in modelAuto-detected from `$casts`**History**Built-inBuilt-in, configurable table name**Guards**`beforeTransitionHook()` in StateMachine classDedicated `TransitionGuard` contract, AND-combined arrays**Type safety**String-based statesEnum-based — IDE autocomplete, type checking### Summary: When to use laravel-enum-states

[](#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
-------------

[](#api-reference)

### Attributes

[](#attributes)

AttributeTargetParameters`#[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

[](#contracts)

InterfaceMethod`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

[](#hasstatemachines-trait)

MethodReturnsDescription`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

[](#query-scopes-1)

ScopeSignature`whereState``whereState(string $field, BackedEnum $state)``whereNotState``whereNotState(string $field, BackedEnum $state)``whereStateIn``whereStateIn(string $field, array $states)`### Events

[](#events-1)

EventProperties`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

[](#exceptions)

ExceptionWhen 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

[](#configuration-1)

KeyDefaultDescription`enum-states.table``state_transitions` (overridable via `ENUM_STATES_TABLE` env)Name of the history table### Publishable assets

[](#publishable-assets)

TagPublishes`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

[](#models)

ClassTableDescription`StateTransition``config('enum-states.table')` (default `state_transitions`)Polymorphic history record with `from`, `to`, `field`, `metadata`, `transitioned_at`License
-------

[](#license)

MIT License. See [LICENSE.md](LICENSE.md) for details.

###  Health Score

39

—

LowBetter than 85% of packages

Maintenance86

Actively maintained with recent releases

Popularity8

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity48

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~6 days

Total

7

Last Release

69d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e92c7132c0dbbe7e5916f08fefa29afb80907c231102686f1de66e55271a7e94?d=identicon)[soylentgreenstudio](/maintainers/soylentgreenstudio)

---

Top Contributors

[![soylentgreenstudio](https://avatars.githubusercontent.com/u/268559501?v=4)](https://github.com/soylentgreenstudio "soylentgreenstudio (9 commits)")

---

Tags

enumlaravelphp8state-machinelaravelenumstate-machinephp-attributestransitions

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/soylentgreenstudio-laravel-enum-states/health.svg)

```
[![Health](https://phpackages.com/badges/soylentgreenstudio-laravel-enum-states/health.svg)](https://phpackages.com/packages/soylentgreenstudio-laravel-enum-states)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.1M337](/packages/psalm-plugin-laravel)[wearepixel/laravel-cart

A cart implementation for Laravel

1355.6k](/packages/wearepixel-laravel-cart)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
