iotron/laravel-state-machine

A robust, enum-aware state machine for Laravel Eloquent models

Fund package maintenance!
iotron

Installs: 1

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/iotron/laravel-state-machine

v1.0.0 2026-02-07 15:28 UTC

This package is auto-updated.

Last update: 2026-02-07 15:28:37 UTC


README

Latest Version on Packagist GitHub Tests Action Status Total Downloads

A robust, enum-aware state machine for Laravel Eloquent models. Define allowed transitions, validate before state changes, track full history, and prevent N+1 queries — all with native PHP BackedEnum support and zero dependencies beyond Laravel.

Features

  • Native BackedEnum support — use enums everywhere, normalized internally
  • N+1 query prevention — eager-load history, get zero-query lookups in loops
  • Transaction-safe transitions — model save + history recording are atomic
  • Lifecycle eventsTransitionStarted, TransitionCompleted, TransitionFailed
  • Validation hooks — block invalid transitions with Laravel Validator
  • Before/after hooks — run closures on specific state changes
  • Pending transitions — schedule future state changes with jobs
  • History tracking — full audit trail with custom properties and changed attributes
  • Safe auth resolution — no crashes in queue/CLI contexts
  • Artisan generatorphp artisan make:state-machine

Requirements

  • PHP 8.2+
  • Laravel 11 or 12

Installation

composer require iotron/laravel-state-machine

Publish the config file:

php artisan vendor:publish --tag=state-machine-config

Publish and run the migrations:

php artisan vendor:publish --tag=state-machine-migrations
php artisan migrate

Migrating from asantibanez/laravel-eloquent-state-machines? See the Migration Guide below — no database changes needed.

Quick Start

1. Create a State Machine

php artisan make:state-machine OrderStatusStateMachine

Define your transitions and default state:

namespace App\StateMachines;

use App\Enums\OrderStatus;
use Iotron\StateMachine\StateMachines\StateMachine;

class OrderStatusStateMachine extends StateMachine
{
    public function transitions(): array
    {
        return [
            'pending'    => ['confirmed', 'cancelled'],
            'confirmed'  => ['dispatched', 'cancelled'],
            'dispatched' => ['delivered'],
        ];
    }

    public function defaultState(): ?string
    {
        return OrderStatus::PENDING->value;
    }
}

2. Add the Trait to Your Model

use Iotron\StateMachine\Concerns\HasStateMachines;

class Order extends Model
{
    use HasStateMachines;

    public $stateMachines = [
        'status' => OrderStatusStateMachine::class,
    ];

    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class, // native enum cast works!
        ];
    }
}

3. Use It

$order = Order::create();

// Query state
$order->status()->is(OrderStatus::PENDING);       // true
$order->status()->canBe(OrderStatus::CONFIRMED);   // true
$order->status()->canBe(OrderStatus::DELIVERED);   // false

// Transition
$order->status()->transitionTo(OrderStatus::CONFIRMED);

// History
$order->status()->was(OrderStatus::PENDING);        // true
$order->status()->timesWas(OrderStatus::PENDING);    // 1
$order->status()->whenWas(OrderStatus::CONFIRMED);   // Carbon
$order->status()->snapshotWhen(OrderStatus::CONFIRMED); // Transition model

// Custom properties
$order->status()->transitionTo('dispatched', ['tracking' => 'ABC123']);
$order->status()->getCustomProperty('tracking'); // 'ABC123'

Configuration

// config/state-machine.php
return [
    'tables' => [
        'transitions'         => 'state_histories',    // history table name
        'pending_transitions' => 'pending_transitions',
    ],
    'record_changed_attributes'    => true,  // capture dirty attributes on transition
    'cancel_pending_on_transition' => true,  // auto-cancel pending when transitioning
];

Defining State Machines

Transitions

The transitions() method returns a map of from => [allowed targets]:

public function transitions(): array
{
    return [
        'draft'     => ['pending', 'cancelled'],
        'pending'   => ['approved', 'rejected'],
        'approved'  => ['published'],
        // Wildcard support
        '*'         => ['archived'],  // any state can go to archived
    ];
}

Default State

Set the initial state for new models:

public function defaultState(): ?string
{
    return 'draft';
    // or: return MyEnum::DRAFT->value;
}

Record History

Control whether transitions are tracked (default: true):

public function recordHistory(): bool
{
    return true;
}

Validation

Return a Validator to block transitions that don't meet requirements:

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Facades\Validator as ValidatorFacade;

public function validatorForTransition($from, $to, $model): ?Validator
{
    if ($to === 'published') {
        $validator = ValidatorFacade::make([], []);

        if (! $model->title) {
            $validator->after(fn ($v) => $v->errors()->add(
                'title', 'A title is required before publishing.'
            ));
        }

        return $validator;
    }

    return null; // no validation for other transitions
}

If the validator fails, a ValidationException is thrown and the model stays unchanged.

Before/After Hooks

Run closures when entering or leaving specific states. All hooks receive ($from, $to, $model):

public function beforeTransitionHooks(): array
{
    return [
        'published' => [ // keyed by the FROM state
            function (string $from, string $to, Model $model) {
                // Runs before leaving 'published'
            },
        ],
    ];
}

public function afterTransitionHooks(): array
{
    return [
        'confirmed' => [ // keyed by the TO state
            function (string $from, string $to, Model $model) {
                $model->update(['confirmed_at' => now()]);
            },
        ],
    ];
}

State Proxy API

Calling $model->status() returns a State proxy with these methods:

Method Returns Description
state() string Current state (normalized to string)
is($state) bool Check if current state matches
isNot($state) bool Check if current state doesn't match
canBe($state) bool Check if transition is allowed
transitionTo($state, $props, $responsible) void Execute transition
postponeTransitionTo($state, $when, ...) ?PendingTransition Schedule future transition
was($state) bool Ever been in this state?
timesWas($state) int Count times in this state
whenWas($state) ?Carbon When last entered this state
snapshotWhen($state) ?Transition Transition record for a state
snapshotsWhen($state) Collection All records for a state
history() Builder Query builder for this field's history
latest() ?Transition Most recent transition to current state
getCustomProperty($key) mixed Custom property from latest transition
responsible() ?Model User who triggered latest transition
allCustomProperties() array All custom properties from latest
pendingTransitions() Builder Query pending transitions
hasPendingTransitions() bool Any pending transitions?

All methods accept both strings and BackedEnum values.

N+1 Prevention

When you eager-load stateHistory, all history lookups use the in-memory collection — zero extra queries:

// 2 queries total: models + stateHistory
$orders = Order::with('stateHistory')->get();

// 0 additional queries for any number of models
foreach ($orders as $order) {
    $order->status()->was(OrderStatus::PENDING);
    $order->status()->timesWas(OrderStatus::CONFIRMED);
    $order->status()->snapshotWhen(OrderStatus::DISPATCHED);
}

Without eager loading, each call falls back to a database query automatically.

Events

Three events fire during transitions for app-wide listening:

Event When Payload
TransitionStarted Before hooks fire $model, $field, $from, $to
TransitionCompleted After everything succeeds $model, $field, $from, $to
TransitionFailed On any exception $model, $field, $from, $to, $exception
// In a listener or EventServiceProvider
use Iotron\StateMachine\Events\TransitionCompleted;

Event::listen(TransitionCompleted::class, function (TransitionCompleted $event) {
    if ($event->field === 'status' && $event->to === 'published') {
        // Send notification, dispatch job, etc.
    }
});

Pending Transitions

Schedule transitions to execute in the future:

$order->status()->postponeTransitionTo('dispatched', Carbon::tomorrow());

Add the dispatcher job to your scheduler:

// bootstrap/app.php or routes/console.php
use Iotron\StateMachine\Jobs\DispatchPendingTransitions;

Schedule::job(new DispatchPendingTransitions)->everyMinute();

The job processes pending transitions in chunks and dispatches each as a separate queued job for reliability.

Transition Model

The Transition model (stored in the state_histories table by default) includes useful scopes:

use Iotron\StateMachine\Models\Transition;

// Query scopes
Transition::forField('status')->to('published')->get();
Transition::withTransition('pending', 'published')->get();
Transition::withCustomProperty('reason', '=', 'approved')->get();
Transition::withResponsible($user)->get();

// Instance methods
$transition->getCustomProperty('key');
$transition->allCustomProperties();
$transition->changedAttributesNames();
$transition->changedAttributeOldValue('title');
$transition->changedAttributeNewValue('title');

Migrating from asantibanez/laravel-eloquent-state-machines

This package is a drop-in replacement. No database migration needed — it reads the same state_histories table by default.

Step 1: Install

composer require iotron/laravel-state-machine

Step 2: Update model imports

- use Asantibanez\LaravelEloquentStateMachines\Traits\HasStateMachines;
+ use Iotron\StateMachine\Concerns\HasStateMachines;

Step 3: Update state machine base class

- use Asantibanez\LaravelEloquentStateMachines\StateMachines\StateMachine;
+ use Iotron\StateMachine\StateMachines\StateMachine;

Step 4: Update hook signatures

The old package used ($from, $model) / ($to, $model). This package uses a consistent ($from, $to, $model) for both before and after hooks:

  public function afterTransitionHooks(): array
  {
      return [
          'confirmed' => [
-             function ($from, $model) {
+             function ($from, $to, $model) {
                  $model->update(['confirmed_at' => now()]);
              },
          ],
      ];
  }

Step 5: Remove old packages

composer remove asantibanez/laravel-eloquent-state-machines javoscript/laravel-macroable-models

Step 6: Enable native enum casts

You can now use Laravel's native enum cast — no more workarounds:

protected function casts(): array
{
    return [
        'status' => OrderStatus::class, // just works!
    ];
}

What's different?

Feature Old Package This Package
Enum support Manual workarounds Native BackedEnum
N+1 prevention Not built-in Built-in via eager loading
Transaction safety No wrapping DB::transaction()
Hook arguments Inconsistent ($to, $model) / ($from, $model) Consistent ($from, $to, $model)
Events None 3 lifecycle events
Auth in queues Crashes Safe fallback
Dependencies Requires laravel-macroable-models Zero external deps
Method resolution Static macros via reflection Native __call()

Testing

composer test

Credits

This package is inspired by and built upon the work of asantibanez/laravel-eloquent-state-machines by Andrés Santibáñez. The original package provided the foundation for Eloquent state machine management that this package extends with native BackedEnum support, N+1 prevention, transaction safety, lifecycle events, and other improvements.

Changelog

Please see CHANGELOG for more information on what has changed recently.

License

The MIT License (MIT). Please see License File for more information.