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.
- 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
- PHP 8.2+
- Laravel 11 or 12
composer require iotron/laravel-state-machinePublish the config file:
php artisan vendor:publish --tag=state-machine-configPublish and run the migrations:
php artisan vendor:publish --tag=state-machine-migrations
php artisan migrateMigrating from
asantibanez/laravel-eloquent-state-machines? See the Migration Guide below — no database changes needed.
php artisan make:state-machine OrderStatusStateMachineDefine 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;
}
}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!
];
}
}$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'// 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
];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
];
}Set the initial state for new models:
public function defaultState(): ?string
{
return 'draft';
// or: return MyEnum::DRAFT->value;
}Control whether transitions are tracked (default: true):
public function recordHistory(): bool
{
return true;
}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.
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()]);
},
],
];
}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.
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.
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.
}
});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.
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');This package is a drop-in replacement. No database migration needed — it reads the same state_histories table by default.
composer require iotron/laravel-state-machine- use Asantibanez\LaravelEloquentStateMachines\Traits\HasStateMachines;
+ use Iotron\StateMachine\Concerns\HasStateMachines;- use Asantibanez\LaravelEloquentStateMachines\StateMachines\StateMachine;
+ use Iotron\StateMachine\StateMachines\StateMachine;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()]);
},
],
];
}composer remove asantibanez/laravel-eloquent-state-machines javoscript/laravel-macroable-modelsYou can now use Laravel's native enum cast — no more workarounds:
protected function casts(): array
{
return [
'status' => OrderStatus::class, // just works!
];
}| 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() |
composer testThis 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.
Please see CHANGELOG for more information on what has changed recently.
The MIT License (MIT). Please see License File for more information.