stateflow / engine
A clean, auditable state machine for PHP 8.2+
Requires
- php: ^8.2
Requires (Dev)
- phpunit/phpunit: ^11.0
This package is auto-updated.
Last update: 2026-03-27 16:15:23 UTC
README
A clean, auditable state machine for PHP 8.2+
Why StateFlow?
Stop scattering state logic across your codebase:
// ❌ Without StateFlow - scattered, error-prone if ($order->status === 'pending' && $action === 'pay') { $order->status = 'paid'; // Hope you remembered to send the email... // Hope you remembered to log it... // Hope no other code sets status to something invalid... }
// ✅ With StateFlow - clean, auditable, testable $machine->apply($order, 'pay');
| Feature | Ad-hoc if statements | StateFlow |
|---|---|---|
| See all states | Scattered | One place |
| See all transitions | Scattered | One place |
| Invalid transitions | Silent bugs | Throws exception |
| Typos | Silent bugs | Throws exception |
| Side effects | Easy to forget | Defined with hooks |
| Audit trail | Build it yourself | Built-in events |
| Testing | Hard | Easy |
| New developer onboarding | "Read the whole codebase" | "Read this class" |
Installation
composer require stateflow/engine
Requires PHP 8.2 or higher.
Quick Start
1. Define your states and transitions
<?php final class OrderStateDefinition { public const STATES = [ 'pending', 'paid', 'shipped', 'cancelled', ]; public const TRANSITIONS = [ 'pay' => [ 'from' => ['pending'], 'to' => 'paid', ], 'ship' => [ 'from' => ['paid'], 'to' => 'shipped', ], 'cancel' => [ 'from' => ['pending', 'paid'], 'to' => 'cancelled', ], ]; }
2. Create and use the state machine
<?php use Stateflow\Engine\StateMachine; $machine = StateMachine::create(OrderStateDefinition::class); // Your subject (from your database, or anywhere) $order = Order::find(1); // $order->status is 'pending' // Apply a transition $machine->apply($order, 'pay'); // $order->status is now 'paid' // Save to your database $order->save();
That's it! The library enforces valid transitions. You handle persistence.
Core Concepts
States
States are the possible values your subject can have. Define them as a const array:
public const STATES = ['draft', 'pending', 'approved', 'rejected'];
Transitions
Transitions define how to move between states. Each transition has:
- A name (e.g.,
'submit') - One or more
fromstates - One
tostate
public const TRANSITIONS = [ 'submit' => [ 'from' => ['draft'], // Can only submit from draft 'to' => 'pending', ], 'approve' => [ 'from' => ['pending'], // Can only approve from pending 'to' => 'approved', ], 'reject' => [ 'from' => ['pending', 'approved'], // Can reject from multiple states 'to' => 'rejected', ], ];
Subjects
A subject is the thing that has state. It can be:
- An object with a public property
- An object with getter/setter methods
- An array
- An Eloquent model
- A Doctrine entity
- Anything
// All of these work: $machine->apply($order, 'pay'); // Object $machine->apply($data, 'pay'); // Array $machine->apply($eloquentModel, 'pay'); // Eloquent
API Reference
Creating a State Machine
$machine = StateMachine::create( definition: OrderStateDefinition::class, // Required workflow: OrderWorkflow::class, // Optional: guards and hooks accessor: new CustomAccessor(), // Optional: custom state access field: 'status', // Optional: default is 'status' initialState: 'pending', // Optional: for initialize() );
Applying Transitions
// Apply a transition (throws if invalid) $machine->apply($subject, 'pay'); // Check if transition is possible if ($machine->can($subject, 'pay')) { $machine->apply($subject, 'pay'); }
Reading State
// Get current state $state = $machine->getState($subject); // 'pending' // Get available transitions from current state $transitions = $machine->getAvailableTransitions($subject); // ['pay', 'cancel']
Initializing New Subjects
$machine = StateMachine::create( definition: OrderStateDefinition::class, initialState: 'pending', ); $order = new Order(); // status is null $machine->initialize($order); // status is now 'pending'
Introspection
// Get all states $machine->getStates(); // ['pending', 'paid', 'shipped', 'cancelled'] // Get all transitions $machine->getTransitions(); // ['pay' => [...], 'ship' => [...], ...] // Export as array (JSON-serializable) $machine->toArray(); // Export as Mermaid diagram echo $machine->toMermaid();
Validation
// Validate without creating (returns errors array) $errors = StateMachine::validate(OrderStateDefinition::class); if (empty($errors)) { echo "Definition is valid!"; }
Guards
Guards are conditions that must pass before a transition is allowed.
Defining Guards
Create a workflow class with guard methods:
<?php use Stateflow\Engine\Attribute\Guard; final class OrderWorkflow { #[Guard('pay')] public function hasValidAmount( object|array $subject, string $transition, string $from, string $to ): bool { return $subject->amount > 0; } #[Guard('ship')] public function hasShippingAddress( object|array $subject, string $transition, string $from, string $to ): bool { return $subject->shipping_address !== null; } }
Using Guards
$machine = StateMachine::create( definition: OrderStateDefinition::class, workflow: OrderWorkflow::class, ); $order->amount = 0; $machine->can($order, 'pay'); // false $machine->apply($order, 'pay'); // throws GuardRejectedException $order->amount = 100; $machine->can($order, 'pay'); // true $machine->apply($order, 'pay'); // works!
Multiple Guards
You can have multiple guards for the same transition. All must pass:
#[Guard('pay')] public function hasValidAmount($subject, ...): bool { return $subject->amount > 0; } #[Guard('pay')] public function hasCustomer($subject, ...): bool { return $subject->customer_id !== null; } // Both must return true for 'pay' to be allowed
Hooks
Hooks run code at specific points during a transition.
Available Hooks
| Hook | Triggers on | When |
|---|---|---|
#[Before('transition')] |
Transition name | After guards, before state change |
#[OnExit('state')] |
State name | Before leaving the state |
#[OnEnter('state')] |
State name | After entering the state |
#[After('transition')] |
Transition name | After everything |
Execution Order
Guard → Before → OnExit → [state change] → OnEnter → After
Defining Hooks
<?php use Stateflow\Engine\Attribute\Guard; use Stateflow\Engine\Attribute\Before; use Stateflow\Engine\Attribute\OnExit; use Stateflow\Engine\Attribute\OnEnter; use Stateflow\Engine\Attribute\After; final class OrderWorkflow { #[Guard('pay')] public function hasValidAmount($subject, $transition, $from, $to): bool { return $subject->amount > 0; } #[Before('pay')] public function logPaymentAttempt($subject, $transition, $from, $to): void { Log::info("Attempting payment for order {$subject->id}"); } #[OnExit('pending')] public function onLeavePending($subject, $transition, $from, $to): void { Log::info("Order {$subject->id} is no longer pending"); } #[OnEnter('paid')] public function sendConfirmationEmail($subject, $transition, $from, $to): void { Mail::send(new OrderConfirmation($subject)); } #[After('pay')] public function notifyWarehouse($subject, $transition, $from, $to): void { Warehouse::notify($subject); } }
Hook Signature
All hooks receive the same parameters:
function( object|array $subject, // The subject being transitioned string $transition, // The transition name ('pay') string $from, // The previous state ('pending') string $to // The new state ('paid') ): void
Guards must return bool. Other hooks return void.
Events & Audit Trail
Listen to transition events for logging, auditing, or triggering side effects.
Registering Listeners
$machine->onTransition(function (TransitionEvent $event) { // Log to database AuditLog::create([ 'entity_type' => get_class($event->subject), 'entity_id' => $event->subject->id, 'transition' => $event->transition, 'from' => $event->from, 'to' => $event->to, 'occurred_at' => $event->occurredAt, 'user_id' => auth()->id(), ]); });
TransitionEvent Properties
$event->subject; // The subject that transitioned $event->transition; // Transition name ('pay') $event->from; // Previous state ('pending') $event->to; // New state ('paid') $event->occurredAt; // DateTimeImmutable
Multiple Listeners
$machine ->onTransition(function ($event) { // Log to database }) ->onTransition(function ($event) { // Send to analytics }) ->onTransition(function ($event) { // Notify webhooks });
Note: Listeners are only called for successful transitions. Failed transitions (invalid state, guard rejected) do not trigger listeners.
Custom State Accessor
By default, StateFlow reads/writes state using common patterns:
- Public properties (
$subject->status) - Getter/setter methods (
$subject->getStatus()) - Array access (
$subject['status']) - Magic methods (
__get,__set)
For special cases, provide a custom accessor:
use Stateflow\Engine\Accessor\CallbackAccessor; $accessor = new CallbackAccessor( getter: fn($subject, $field) => $subject->getCurrentState(), setter: fn(&$subject, $field, $state) => $subject->changeStateTo($state), ); $machine = StateMachine::create( definition: OrderStateDefinition::class, accessor: $accessor, );
Or implement StateAccessorInterface:
use Stateflow\Engine\Accessor\StateAccessorInterface; final class MyCustomAccessor implements StateAccessorInterface { public function getState(object|array $subject, string $field): ?string { // Your logic } public function setState(object|array &$subject, string $field, string $state): void { // Your logic } }
Visualizing Your State Machine
Export to Mermaid for documentation:
echo $machine->toMermaid();
Output:
stateDiagram-v2
[*] --> pending
pending --> paid : pay
paid --> shipped : ship
pending --> cancelled : cancel
paid --> cancelled : cancel
Renders as:
stateDiagram-v2
[*] --> pending
pending --> paid : pay
paid --> shipped : ship
pending --> cancelled : cancel
paid --> cancelled : cancel
Loading
Paste this into GitHub, GitLab, Notion, or any Mermaid-compatible tool.
Framework Integration
Laravel / Eloquent
// app/StateMachines/OrderStateDefinition.php final class OrderStateDefinition { public const STATES = ['pending', 'paid', 'shipped', 'cancelled']; public const TRANSITIONS = [ 'pay' => ['from' => ['pending'], 'to' => 'paid'], 'ship' => ['from' => ['paid'], 'to' => 'shipped'], 'cancel' => ['from' => ['pending', 'paid'], 'to' => 'cancelled'], ]; } // app/Models/Order.php class Order extends Model { public function pay(): void { $machine = StateMachine::create(OrderStateDefinition::class); $machine->apply($this, 'pay'); $this->save(); } } // Usage $order = Order::find(1); $order->pay();
With Service Container
// AppServiceProvider $this->app->singleton('order.machine', function () { return StateMachine::create( definition: OrderStateDefinition::class, workflow: OrderWorkflow::class, initialState: 'pending', ); }); // Usage app('order.machine')->apply($order, 'pay');
Symfony / Doctrine
// src/Entity/Order.php #[ORM\Entity] class Order { #[ORM\Column] private string $status = 'pending'; public function getStatus(): string { return $this->status; } public function setStatus(string $status): void { $this->status = $status; } } // Usage $machine = StateMachine::create(OrderStateDefinition::class); $machine->apply($order, 'pay'); $entityManager->flush();
Error Handling
Exception Types
use Stateflow\Engine\Exception\InvalidTransitionException; use Stateflow\Engine\Exception\InvalidStateException; use Stateflow\Engine\Exception\GuardRejectedException; use Stateflow\Engine\Exception\ValidationException; use Stateflow\Engine\Exception\StateAccessException; try { $machine->apply($order, 'pay'); } catch (InvalidTransitionException $e) { // Transition doesn't exist or not allowed from current state } catch (GuardRejectedException $e) { // A guard blocked the transition echo "Blocked by: " . $e->guardMethod; } catch (InvalidStateException $e) { // Subject has invalid state } // Catch all StateFlow exceptions use Stateflow\Engine\Exception\StateflowExceptionInterface; try { $machine->apply($order, 'pay'); } catch (StateflowExceptionInterface $e) { // Any StateFlow error }
Testing
StateFlow makes testing easy:
public function test_order_can_be_paid(): void { $machine = StateMachine::create(OrderStateDefinition::class); $order = ['status' => 'pending', 'amount' => 100]; $machine->apply($order, 'pay'); $this->assertSame('paid', $order['status']); } public function test_shipped_order_cannot_be_cancelled(): void { $machine = StateMachine::create(OrderStateDefinition::class); $order = ['status' => 'shipped']; $this->assertFalse($machine->can($order, 'cancel')); } public function test_guard_blocks_zero_amount(): void { $machine = StateMachine::create( definition: OrderStateDefinition::class, workflow: OrderWorkflow::class, ); $order = ['status' => 'pending', 'amount' => 0]; $this->expectException(GuardRejectedException::class); $machine->apply($order, 'pay'); }
Complete Example
<?php use Stateflow\Engine\StateMachine; use Stateflow\Engine\Attribute\Guard; use Stateflow\Engine\Attribute\OnEnter; use Stateflow\Engine\Event\TransitionEvent; // 1. Definition (pure data) final class OrderStateDefinition { public const STATES = ['pending', 'paid', 'shipped', 'cancelled']; public const TRANSITIONS = [ 'pay' => ['from' => ['pending'], 'to' => 'paid'], 'ship' => ['from' => ['paid'], 'to' => 'shipped'], 'cancel' => ['from' => ['pending', 'paid'], 'to' => 'cancelled'], ]; } // 2. Workflow (behavior) final class OrderWorkflow { #[Guard('pay')] public function hasValidAmount($subject, $transition, $from, $to): bool { return $subject->amount > 0; } #[Guard('ship')] public function hasShippingAddress($subject, $transition, $from, $to): bool { return $subject->shipping_address !== null; } #[OnEnter('paid')] public function sendConfirmation($subject, $transition, $from, $to): void { Mail::send(new OrderConfirmation($subject)); } #[OnEnter('shipped')] public function sendTrackingInfo($subject, $transition, $from, $to): void { Mail::send(new ShippingNotification($subject)); } #[OnEnter('cancelled')] public function processRefund($subject, $transition, $from, $to): void { if ($from === 'paid') { PaymentGateway::refund($subject); } } } // 3. Usage $machine = StateMachine::create( definition: OrderStateDefinition::class, workflow: OrderWorkflow::class, initialState: 'pending', ); // Audit trail $machine->onTransition(function (TransitionEvent $event) { AuditLog::create([ 'order_id' => $event->subject->id, 'transition' => $event->transition, 'from' => $event->from, 'to' => $event->to, 'occurred_at' => $event->occurredAt, ]); }); // Initialize new order $order = new Order(); $machine->initialize($order); // status = 'pending' // Process order $order->amount = 99.99; $order->shipping_address = '123 Main St'; $machine->apply($order, 'pay'); // Sends confirmation email $machine->apply($order, 'ship'); // Sends tracking info // Check what's possible $machine->getAvailableTransitions($order); // [] - shipped is final // Visualize echo $machine->toMermaid();
License
MIT License. See LICENSE for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Credits
Created by Enmanuel Varela Pividori.