stateflow/engine

A clean, auditable state machine for PHP 8.2+

Maintainers

Package info

github.com/EnmanuelVarelaPividori/stateflow-engine

pkg:composer/stateflow/engine

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 8

v1.0.1 2026-01-27 15:05 UTC

This package is auto-updated.

Last update: 2026-03-27 16:15:23 UTC


README

A clean, auditable state machine for PHP 8.2+

Latest Version PHP Version License

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 from states
  • One to state
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.