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
Requires
- php: ^8.2
- illuminate/contracts: ^11.0||^12.0
- illuminate/database: ^11.0||^12.0
- illuminate/support: ^11.0||^12.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.1
- orchestra/testbench: ^9.0||^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
README
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 events —
TransitionStarted,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 generator —
php 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.