winzou/state-machine
Lightweight PHP state machine library. Define graphs with states, transitions, and guard/before/after callbacks, then apply and validate transitions on your domain objects. Supports multiple graphs per object and configurable state property paths.
composer require winzou/state-machine
Order model):
use Winzou\StateMachine\StateMachineFactory;
$config = [
'graph' => 'orderWorkflow',
'property_path' => 'status', // Eloquent attribute or property
'states' => ['draft', 'submitted', 'paid', 'shipped', 'cancelled'],
'transitions' => [
'submit' => ['from' => 'draft', 'to' => 'submitted'],
'pay' => ['from' => 'submitted', 'to' => 'paid'],
'ship' => ['from' => 'paid', 'to' => 'shipped'],
'cancel' => ['from' => ['submitted', 'paid'], 'to' => 'cancelled'],
],
'callbacks' => [
'guard' => [
'guard-pay' => [
'from' => 'submitted',
'do' => fn(Order $order) => $order->payment->isSuccessful(),
],
],
'after' => [
'on-pay' => [
'on' => 'pay',
'do' => fn(Order $order) => $order->sendPaymentConfirmation(),
],
],
],
];
$factory = new StateMachineFactory();
$stateMachine = $factory->get($order, $config);
$stateMachine->apply('pay'); // Throws \RuntimeException if invalid
// In a Laravel controller:
public function pay(Order $order)
{
$stateMachine = app('state_machine.factory')->get($order, 'orderWorkflow');
$stateMachine->apply('pay'); // Transitions from 'submitted' to 'paid'
return redirect()->route('order.show', $order);
}
Bind the factory as a singleton in AppServiceProvider:
public function register()
{
$this->app->singleton('state_machine.factory', function () {
return new \Winzou\StateMachine\StateMachineFactory();
});
}
Access via dependency injection:
public function __construct(private StateMachineFactory $factory) {}
Store the state in a database column (e.g., status):
// Order.php
protected $casts = [
'status' => 'string', // Stores 'draft', 'submitted', etc.
];
Define the graph in a trait or model method:
// Order.php
public function getStateMachineConfig()
{
return [
'graph' => 'orderWorkflow',
'property_path' => 'status',
// ... rest of config
];
}
Load configurations from a database or YAML:
// config/state_machines/order_workflow.php
return [
'states' => ['draft', 'submitted', 'paid'],
'transitions' => [
'submit' => ['from' => 'draft', 'to' => 'submitted'],
],
];
Merge with runtime data:
$config = array_merge(
require config_path('state_machines/order_workflow.php'),
['callbacks' => ['guard' => [...]]]
);
'guard' => [
'guard-pay' => [
'do' => fn(Order $order) => auth()->user()->can('pay-orders'),
],
],
'guard' => [
'guard-ship' => [
'from' => 'paid',
'do' => fn(Order $order) => $order->inventory->isAvailable(),
],
],
'before' => [
'validate-payment' => [
'on' => 'pay',
'do' => fn(Order $order) => $order->validatePayment(),
],
],
'after' => [
'notify-shipper' => [
'on' => 'ship',
'do' => fn(Order $order) => $order->notifyShipper(),
],
],
Attach multiple state machines to a single model (e.g., Order with payment and shipping workflows):
// Order.php
public function getPaymentGraphConfig() { /* ... */ }
public function getShippingGraphConfig() { /* ... */ }
Apply transitions separately:
$paymentMachine->apply('charge');
$shippingMachine->apply('schedule');
$factory = Mockery::mock(StateMachineFactory::class);
$factory->shouldReceive('get')->andReturn($mockedMachine);
$this->expectException(RuntimeException::class);
$stateMachine->apply('pay'); // Fails if guard returns false
$this->assertEquals('paid', $order->status);
State Property Path Mismatch:
property_path in the config matches the exact attribute name (e.g., status vs. order_status).getAttribute() in callbacks if the property is dynamic:
'do' => fn(Order $order) => $order->getAttribute('status'),
Guard Logic Errors:
null, string) will throw exceptions.true/false:
'do' => fn() => $this->checkPermission() ?: false,
Callback Context:
$this. Avoid use($this) in closures:
// ❌ Wrong (captures outer scope)
'do' => function() { $this->log(); } // Fails: $this is the object, not the class
// ✅ Correct
'do' => fn(Order $order) => $order->logTransition(),
Transition Validation:
apply() throws \RuntimeException on failure. Use can() to check first:
if ($stateMachine->can('pay')) {
$stateMachine->apply('pay');
}
Multiple Graphs on Same Object:
graph key in config). Reusing names will overwrite the state machine.order_payment_workflow, order_shipping_workflow).Database Sync:
$stateMachine->apply('pay');
$order->save(); // Required!
Legacy State Handling:
apply() will throw an exception. Handle this in guards or pre-transition callbacks:
'guard' => [
'validate-state' => [
'do' => fn(Order $order) => in_array($order->status, $config['states']),
],
],
Enable Callback Logging: Add debug output to callbacks to trace execution:
'after' => [
'debug-transition' => [
'do' => fn($object, $transition) => logger()->debug("Transition: {$transition}"),
],
],
Check Transition Paths:
Use getAllowedTransitions() to list valid transitions from the current state:
$allowed = $stateMachine->getAllowedTransitions();
// Outputs: ['pay' => ['from' => 'submitted', 'to' => 'paid']]
Validate Config:
Use the validate() method to check the graph config:
$factory->validate($config); // Throws \InvalidArgumentException on errors
CallbackHandler to add logging or metrics:
use Winzou\StateMachine\CallbackHandler;
class LoggingCallbackHandler extends CallbackHandler
{
public function execute($callback, $object, $transition)
{
logger()->info("Executing callback: {$
How can I help you explore Laravel packages today?