Search by

php-architecture-kit / state-machine

patrykbaszak

Graph-based state machine engine for PHP — supports parallel pointers, conditional transitions, typed state, and domain events.

Package info

github.com/php-architecture-kit/state-machine

pkg:composer/php-architecture-kit/state-machine

Statistics

Installs: 37

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 4


README

Graph-based state machine engine for PHP. Supports parallel pointers, conditional transitions, typed state, pluggable transition strategies, and task dispatch — all built on top of php-architecture-kit/graph.

Features

  • Graph-backed — transitions are directed edges, nodes are vertices
  • Parallel pointers — multiple execution cursors moving through the graph simultaneously
  • Conditional transitions — guard each edge with a TransitionCondition
  • Typed state — key-value State objects attached to each Execution
  • Domain events — pointer and state lifecycle emits DomainEvent instances
  • Pluggable strategies — swap transition selection and pointer scheduling strategies
  • Built-in components — reusable higher-level building blocks for common patterns
  • PHP 8.4+ — uses asymmetric visibility and readonly classes

Installation

composer require php-architecture-kit/state-machine

Quick Start

use PhpArchitecture\StateMachine\Foundation\StateMachine;
use PhpArchitecture\StateMachine\Foundation\Execution\Execution;
use PhpArchitecture\StateMachine\Foundation\Node\Node;
use PhpArchitecture\StateMachine\Foundation\Node\Handler\NodeHandlerInterface;
use PhpArchitecture\StateMachine\Foundation\Node\Handler\NodeHandlerContext;
use PhpArchitecture\StateMachine\Foundation\Node\Handler\NodeHandlerResult;
use Psr\Container\ContainerInterface;

// 1. Define a node - its globally unique name ({vendor}.{module}.{purpose}) is also its identity
final class SendEmailNode extends Node
{
    public function handlerClass(): string
    {
        return SendEmailHandler::class;
    }
}

// 2. Implement the handler (resolved from the PSR-11 container)
final class SendEmailHandler implements NodeHandlerInterface
{
    public function handle(NodeHandlerContext $context): NodeHandlerResult
    {
        // do work...
        return NodeHandlerResult::Continue;
    }
}

// 3. Define the workflow
final class OrderWorkflow extends StateMachine
{
    public readonly SendEmailNode $send;

    public function __construct(ContainerInterface $container)
    {
        parent::__construct($container);

        $this->send = new SendEmailNode('acme.orders.send-email');
        $notify     = new NotifyAdminNode('acme.orders.notify-admin');

        $this->addNode($this->send)
             ->addNode($notify)
             ->addTransition($this->send->id, $notify->id);
    }
}

// 4. Run
$machine   = new OrderWorkflow($container);
$execution = Execution::create();
$execution->pointers->startAt($machine->send->id);

$status = $machine->execute($execution);
// ExecutionStatus::Completed | ::Running | ::Suspended

Core Concepts

StateMachine

Extend StateMachine and register nodes and transitions in the constructor (or any method you choose). Call execute(Execution $execution) to advance all active pointers.

$status = $machine->execute($execution);

Returns:

  • ExecutionStatus::Completed — all pointers finished
  • ExecutionStatus::Running — progress was made but pointers remain
  • ExecutionStatus::Suspended — no progress, all pointers are blocked

Pointers, pointer events and technical state keys carry only NodeIds. The definition can be read back to turn them into nodes, for example in a log or audit trail:

$machine->getNode($pointer->nodeId)->name(); // throws NodeNotFoundException for an unknown id
$machine->hasNode($nodeId);
$machine->getNodes();       // array<string,NodeInterface> keyed by node id
$machine->getTransitions(); // Transition[]

Node

Extend abstract class Node for each step in the workflow. Override handlerClass() to return the FQCN of the NodeHandlerInterface implementation resolved by the PSR-11 container.

Override transitionStrategy() to control how outgoing transitions are selected for that node (defaults to AllValidTransitionsStrategy).

NodeHandlerInterface

interface NodeHandlerInterface
{
    public function handle(NodeHandlerContext $context): NodeHandlerResult;
}

NodeHandlerContext provides:

  • ExecutionId $executionId
  • NodeInterface $node
  • Pointer $pointer
  • States $states
  • dispatchTask(Task $task, array $stamps = []): void

Return NodeHandlerResult::Continue to let the pointer move on, or NodeHandlerResult::Suspended to pause it in place without advancing.

Transition

Directed edge between two NodeIds. Optionally attach a guard condition:

$this->addTransition($from->id, $to->id);

// with a condition (any TransitionCondition; TransitionConditionCallback wraps a closure)
$this->addTransition($from->id, $to->id, TransitionConditionCallback::define(
    fn(States $states): TransitionConditionDecision => $states->getState('order') !== null
        ? TransitionConditionDecision::Accepted
        : TransitionConditionDecision::Wait,
));

Execution

Holds all Pointers and States for one running instance.

$execution = Execution::create();
$execution->pointers->startAt($entryNodeId);

Pointer

Tracks a single cursor position (nodeId). Pointers can be forked for parallel branches. Retrieve events (fork, remove, etc.) via $execution->pointers->getEvents().

State

Named key-value bags attached to an execution. The whole graph shares them and addresses them by name.

// write: defines the state, or sets the given details and keeps the others
$execution->states->defineOrModifyState('order', ['status' => 'pending', 'amount' => 1500]);

// read inside a handler or transition condition
$status = $context->states->getState('order')?->value('status') ?? 'new';
$order = $context->states->getState('order')?->values(); // ['status' => 'pending', 'amount' => 1500]

$context->states->removeStateByName('order'); // does nothing when there is no such state
  • defineOrModifyState() delegates to defineState() / modifyState(), so the policies run and the events are recorded as usual. Only details whose value is not identical (===) to the stored one are set; when nothing changes, no event is recorded. An object is compared by identity, so an equal but new object counts as a change.
  • The write methods look only at stored states. Writing a name that a StateResolverInterface provides defines a stored state, which takes precedence over the resolver from then on.
  • Names are not unique: defineState() accepts an existing name, and every name-based method works on the first state with that name. Use defineOrModifyState() to keep one state per name.
  • Details are keyed by their name. A value under a numeric key has no name, so it must be a StateDetail; a plain value there is rejected (InvalidInputProvidedToStateDefinition when defining, CannotModifyStateException when modifying). What a detail holds, e.g. a list, is up to you.
  • State::value($detail, $default) returns a stored null as null; $default is used only when the detail does not exist.
  • defineState(), modifyState(StateId, …) and removeState(StateId) remain available.

StateMachineConfig

Controls graph constraints and pluggable strategies:

new StateMachineConfig(
    allowCycles: true,
    allowSelfLoops: true,
    allowParallelTransitions: true,
    transitionStrategies: [...],
    pointersSelectionStrategy: new AllPointersUntilBlockedStrategy(),
)

Built-in Components

Components are reusable, pre-wired sub-graphs (Definition) with named input and output ports. Connect the ports to your nodes with attach() before adding the component with $machine->addDefinition($component) - the compiler replaces every port with the node attached to it.

$component->input->trigger->attach($previousNode->id);   // previous node -> component
$component->output->done->attach($nextNode->id);         // component -> next node
$machine->addDefinition($component);

Components use the package's own node handlers (PassthroughNodeHandler, AwaitStateNodeHandler, CreateAsyncTaskNodeHandler, JoinArrivalNodeHandler, AwaitAllArrivalNodeHandler). They have no dependencies: when your container does not provide them, the machine creates them itself.

AwaitStateComponent

Waits until a named state (optionally with a given detail) appears in States, with an optional timeout. Ports: input at, outputs run and expired. The timeout window starts on every visit, so the component can be re-entered through a cycle. expired becomes available once exactly timeout seconds have passed since the visit (now >= visit + timeout).

$await = AwaitStateComponent::create('payment', 'payment_result', detailName: 'status', timeout: 60, clock: $clock);

$await->input->at->attach($prevNode->id);
$await->output->run->attach($nextNode->id);
$await->output->expired->attach($timeoutNode->id);
$machine->addDefinition($await);

ChoiceComponent

Routes to the first output whose predicate accepts (XOR gateway). Port: input trigger; one output per branch.

$choice = ChoiceComponent::create('decision', [
    'approved' => fn(States $s): TransitionConditionDecision => $s->getState('decision')?->value('value') === 'approved'
        ? TransitionConditionDecision::Accepted
        : TransitionConditionDecision::Rejected,
    'rejected' => null, // no predicate - always matches, so keep it last
]);

$choice->input->trigger->attach($prevNode->id);
$choice->output->approved->attach($approvedNode->id);
$choice->output->rejected->attach($rejectedNode->id);
$machine->addDefinition($choice);

ForkComponent

Spawns one pointer per branch (AND-split). Branches without a predicate always fire. Port: input trigger; one output per branch.

$fork = ForkComponent::create('notify', ['email', 'sms', 'audit'], [
    'sms' => fn(States $s): TransitionConditionDecision => $s->getState('user')?->value('hasSms') === true
        ? TransitionConditionDecision::Accepted
        : TransitionConditionDecision::Rejected,
]);

$fork->input->trigger->attach($prevNode->id);
$fork->output->email->attach($emailNode->id);
$fork->output->sms->attach($smsNode->id);
$fork->output->audit->attach($auditNode->id);
$machine->addDefinition($fork);

JoinComponent

AND-join: continues once every declared input has a pointer. Ports: one input per declared input (at least two), output done.

$join = JoinComponent::create('notify', ['email', 'sms', 'audit']);

$join->input->email->attach($emailNode->id);
$join->input->sms->attach($smsNode->id);
$join->input->audit->attach($auditNode->id);
$join->output->done->attach($nextNode->id);
$machine->addDefinition($join);

Pointers wait at their input, so an Execution with a pointer waiting at the join stays Suspended. A round takes one pointer from every input: the pointer that completes the round continues through done, and the other pointers of the round are removed. When they are not removed in the same pass, execute() returns Running and the next call removes them, so keep calling execute() while it returns Running. A second pointer arriving on the same input waits for the next round. All pointers are treated alike, whether they come from a fork or were started by a signal. The counters live in the technical state of the Execution and drop back to zero after every round, so the join can be re-entered through a cycle and the Execution can be persisted at any point.

AwaitAllComponent (deprecated)

Deprecated since 1.10.0, removed in 2.0 - use JoinComponent. AND-join: waits for every declared branch before continuing. Ports: one input per branch, output done. Arrivals are remembered for the lifetime of the Execution, so the component does not work when re-entered through a cycle. Several pointers arriving on one input in the same round are merged into that round.

Migrating to JoinComponent. The signature and the ports are the same, only the class changes:

// before
$join = AwaitAllComponent::create('notify', ['email', 'sms', 'audit']);

// after
$join = JoinComponent::create('notify', ['email', 'sms', 'audit']);

The two components keep their state under different nodes. Deploy the change when no persisted Execution has a pointer waiting at this join; otherwise that pointer waits in a node that is no longer in the definition (NodeNotFoundException) and the arrivals recorded so far are not counted.

RaceComponent

The first pointer to arrive continues, one pointer from every other input loses and is removed. Ports: one input per declared input (at least two), output winner.

$race = RaceComponent::create('reply', ['answer', 'timeout']);

$race->input->answer->attach($answeredNode->id);
$race->input->timeout->attach($timedOutNode->id);
$race->output->winner->attach($nextNode->id);
$machine->addDefinition($race);

A round is one arrival per input. The winner leaves at once, and every other input owes one loser: the next pointer arriving there is removed. All pointers are treated alike, whether they come from a fork or were started by a signal. The counters live in the technical state of the Execution and drop back to zero once all losers have arrived, so the race can be re-entered through a cycle and the Execution can be persisted at any point. A loser that never reaches the race is never removed and keeps the Execution from completing.

RaceFirstComponent (deprecated)

Deprecated since 1.10.0, removed in 2.0 - use RaceComponent. The first pointer to arrive continues, every later one is terminated. Ports: input gateway, output winner. The winner is remembered for the lifetime of the Execution, so the component does not work when re-entered through a cycle.

Migrating to RaceComponent. Give each competing branch its own input instead of attaching all of them to gateway:

// before
$race = RaceFirstComponent::create('reply');
$race->input->gateway->attach($answeredNode->id);
$race->input->gateway->attach($timedOutNode->id);

// after
$race = RaceComponent::create('reply', ['answer', 'timeout']);
$race->input->answer->attach($answeredNode->id);
$race->input->timeout->attach($timedOutNode->id);

The two components keep their state under different nodes. Deploy the change when no persisted Execution has an open round of this race (a winner has passed but a loser is still on its way); otherwise the new component does not know about that loser and lets it win.

AsyncComponent

Dispatches a Task once per visit, then waits until the task handler reports the result. Ports: input trigger, outputs success, fail and expired. expired follows the same rule as in AwaitStateComponent: available once exactly timeout seconds have passed since the visit.

$async = AsyncComponent::create(
    'payment',
    fn(States $s): Task => new ProcessPaymentTask($s->getState('order')?->value('id')),
    timeout: 120,
);

$async->input->trigger->attach($prevNode->id);
$async->output->success->attach($paidNode->id);
$async->output->fail->attach($declinedNode->id);
$async->output->expired->attach($timeoutNode->id);
$machine->addDefinition($async);

The task envelope carries an AwaitStateStamp. The task handler - in the same process (ImmediateTaskBus) or later, after the Execution was persisted (DeferredTaskBus) - reports the result and the machine is driven again:

AsyncTaskHandledNotifier::success($execution, $envelope); // or ::fail()
$machine->execute($execution); // pointer resumes

A result of a task dispatched by an earlier visit of the same component (a late or duplicated delivery after a cycle) is ignored - notify() returns false then.

Built-in Transition Strategies

Strategy Behaviour
AllValidTransitionsStrategy Evaluates all outgoing transitions; passes accepted ones forward
ForkTransitionStrategy Forks a new pointer for each accepted transition (used internally)
WaitStrategy Suspends the pointer when all transitions return Wait
WaitAndForkStrategy Waits, then forks when multiple transitions become valid
RejectStrategy Throws when no strategy matched (safety net)

Built-in Pointer Selection Strategies

Strategy Behaviour
AllPointersUntilBlockedStrategy Advances every pointer until each one blocks (default)
AllPointersStepStrategy Advances every pointer exactly one step per execute() call

License

MIT