webrek / laravel-state-machine
Declarative state machines for Eloquent models with guards, events and transition history.
Requires
- php: ^8.2
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- infection/infection: ^0.29
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
README
State machines declarativas para modelos Eloquent. Define los estados en los que puede estar un modelo y las transiciones entre ellos, y deja que el paquete garantice que solo ocurran transiciones válidas — con guards, eventos y un registro de auditoría opcional.
Inicio rápido
composer require webrek/laravel-state-machine
Define una máquina:
use Webrek\StateMachine\StateMachine; use Webrek\StateMachine\Transition; class OrderStatus extends StateMachine { public function states(): array { return ['pending', 'paid', 'shipped', 'delivered', 'cancelled']; } public function transitions(): array { return [ 'pay' => Transition::from('pending')->to('paid'), 'ship' => Transition::from('paid')->to('shipped') ->guard(fn ($order) => filled($order->address)), 'deliver' => Transition::from('shipped')->to('delivered'), 'cancel' => Transition::from(['pending', 'paid'])->to('cancelled'), ]; } public function initialState(): string { return 'pending'; } }
Enlázala a un atributo del modelo:
use Illuminate\Database\Eloquent\Model; use Webrek\StateMachine\Concerns\HasStateMachines; class Order extends Model { use HasStateMachines; public function stateMachines(): array { return ['status' => OrderStatus::class]; } }
Úsala:
$order = Order::create(); // status inicializado en "pending" $order->stateMachine()->can('pay'); // true $order->stateMachine()->allowed(); // ['pay', 'cancel'] $order->stateMachine()->apply('pay'); // status ahora es "paid", persistido $order->stateMachine()->apply('deliver'); // lanza TransitionNotAllowedException
Por qué una state machine en lugar de sentencias if
El estado de un pedido, una suscripción, un ticket de soporte o una solicitud de
KYC rara vez es una cadena de texto libre: es un conjunto de estados con nombre y
reglas estrictas sobre cuál puede seguir a cuál. Codificar esas reglas como
verificaciones if ($order->status === 'paid') dispersas significa que las
reglas viven en una docena de lugares y nada impide un salto inválido como
pending → delivered.
Una state machine pone las reglas en una sola declaración y las hace cumplir:
- Las transiciones inválidas no pueden ocurrir. Aplicar una transición desde el estado equivocado lanza una excepción en lugar de corromper tus datos en silencio.
- Los guards condicionan las transiciones a reglas de negocio. "No puedes enviar sin una dirección" se convierte en un guard, no en un comentario de code review.
- Cada cambio emite un evento. Engancha efectos secundarios (enviar el
recibo, notificar al almacén) a
StateTransitioneden lugar de buscar cada setter. - Obtienes un registro de auditoría gratis. El historial opcional registra quién movió qué, desde dónde, hacia dónde y cuándo.
La API del handler
$model->stateMachine($attribute) devuelve un handler. Con una sola máquina el
atributo es opcional.
$sm = $order->stateMachine('status'); $sm->state(); // 'paid' $sm->is('paid'); // true $sm->can('ship'); // bool — permitido desde aquí Y el guard pasa $sm->allowed(); // ['ship', ... ] nombres de transiciones disponibles ahora $sm->canTransitionTo('shipped'); // bool $sm->apply('ship', ['carrier' => 'DHL']); // devuelve el modelo $sm->history(); // Collection de transiciones registradas
El arreglo de contexto que se pasa a apply() llega a los guards y a los eventos
despachados, y se almacena junto con la fila del historial.
Guards
Un guard es un closure que recibe el modelo y el arreglo de contexto. La
transición solo se permite cuando devuelve true.
'refund' => Transition::from('paid')->to('refunded') ->guard(fn ($order, array $context) => $context['approved_by'] ?? false),
can() devuelve false cuando un guard la bloquea; apply() lanza
GuardFailedException.
Efectos de transición (atómicos)
Adjunta un efecto secundario a una transición con ->using(). Se ejecuta dentro
de la misma transacción de base de datos que el cambio de estado y el
registro del historial, así que todo es todo-o-nada: si el efecto lanza una
excepción, el estado nunca se mueve.
'refund' => Transition::from('paid')->to('refunded') ->using(function ($order, array $context) { $order->payment->refund(); // si esto lanza una excepción... $order->refund_reference = $context['reference']; $order->save(); }),
Si payment->refund() lanza una excepción, la transición se revierte — el pedido
permanece en paid, no se escribe ninguna fila de historial y el modelo en
memoria se restaura. Sin transiciones aplicadas a medias.
Diagrama
Renderiza cualquier máquina como un diagrama de estados de Mermaid:
$order->stateMachine()->toMermaid(); // o, para una clase de definición: (new OrderStatus)->toMermaid();
php artisan state-machine:diagram "App\\States\\OrderStatus"
stateDiagram-v2
[*] --> pending
pending --> paid: pay
paid --> shipped: ship
shipped --> delivered: deliver
pending --> cancelled: cancel
paid --> cancelled: cancel
Loading
Pega la salida en un bloque ```mermaid de Markdown (GitHub lo renderiza) o en cualquier editor en vivo de Mermaid.
Eventos
Dos eventos se disparan alrededor de cada transición:
Webrek\StateMachine\Events\StateTransitioning— antes de guardar el nuevo estado.Webrek\StateMachine\Events\StateTransitioned— después de guardarlo.
Ambos llevan el modelo, el atributo, from, to, el nombre de la transición y el contexto.
Event::listen(StateTransitioned::class, function ($event) { if ($event->transition === 'ship') { Notification::send($event->model->customer, new OrderShipped($event->model)); } });
Historial de transiciones
El historial es opcional. Publica y ejecuta la migración, luego actívalo:
php artisan vendor:publish --tag=state-machine-migrations php artisan migrate
STATE_MACHINE_HISTORY=true
Cada transición aplicada queda entonces registrada, y ->history() devuelve el
rastro, del más antiguo al más reciente:
$order->stateMachine()->history()->each(function ($row) { echo "{$row->from_state} → {$row->to_state} via {$row->transition}"; });
Cada fila almacena el sujeto (morph), el campo, from_state, to_state, el
nombre de la transición, el contexto JSON y las marcas de tiempo.
Múltiples máquinas por modelo
Un modelo puede manejar varios atributos a la vez:
public function stateMachines(): array { return [ 'status' => OrderStatus::class, 'payment_status' => PaymentStatus::class, ]; } $order->stateMachine('payment_status')->apply('authorize');
Requisitos
| Componente | Versión |
|---|---|
| PHP | 8.2+ |
| Laravel | 12.x / 13.x |
Pruebas
composer install
composer test
Contribuir
Consulta CONTRIBUTING.md.
Seguridad
Por favor revisa la política de seguridad antes de reportar una vulnerabilidad.
Licencia
The MIT License (MIT). Consulta LICENSE.