Finite State Machine (FSM) in Pure PHP
PHP Code Editor
Execution Result
Ready to execute
Click the "Run Script" button to see the output here
Description
A finite state machine (FSM) models a system that is always in exactly one of a finite set of states. Transitions between states are triggered by named events and are only allowed if explicitly defined. This makes FSMs predictable and easy to reason about compared to ad-hoc if/switch logic.
This implementation stores transitions in a two-level map: $transitions[fromState][event] = toState. Calling trigger($event) looks up the allowed transition for the current state, moves to the new state, records it in history, and fires the registered callback. If the event is not allowed from the current state, a LogicException is thrown immediately, which prevents invalid state jumps silently.
The on() method accepts either a string or an array of source states, which is useful for transitions that can fire from multiple states (e.g. cancel from both pending and paid). Callbacks receive $from, $to, and $event as arguments.
<?php
/**
* Generic finite state machine.
* Define states, allowed transitions, and optional callbacks.
*/
class StateMachine
{
private string $state;
private array $transitions = [];
private array $callbacks = [];
private array $history = [];
public function __construct(string $initialState)
{
$this->state = $initialState;
$this->history[] = $initialState;
}
/** Allow a transition: on($event, $from, $to, $callback) */
public function on(string $event, string|array $from, string $to, ?callable $callback = null): static
{
$froms = is_array($from) ? $from : [$from];
foreach ($froms as $f) {
$this->transitions[$f][$event] = $to;
}
if ($callback) {
$this->callbacks[$event] = $callback;
}
return $this;
}
public function trigger(string $event): void
{
$allowed = $this->transitions[$this->state] ?? [];
if (!isset($allowed[$event])) {
throw new LogicException(
"Cannot trigger '$event' from state '{$this->state}'. " .
"Allowed: " . implode(', ', array_keys($allowed))
);
}
$from = $this->state;
$this->state = $allowed[$event];
$this->history[] = $this->state;
if (isset($this->callbacks[$event])) {
($this->callbacks[$event])($from, $this->state, $event);
}
}
public function getState(): string { return $this->state; }
public function getHistory(): array { return $this->history; }
public function can(string $event): bool { return isset($this->transitions[$this->state][$event]); }
}
// Demo: order lifecycle
$order = new StateMachine('pending');
$log = fn(string $from, string $to, string $event) =>
printf(" [%s] %s -> %s\n", strtoupper($event), $from, $to);
$order
->on('pay', 'pending', 'paid', $log)
->on('ship', 'paid', 'shipped', $log)
->on('deliver','shipped', 'delivered', $log)
->on('cancel', ['pending', 'paid'], 'cancelled', $log)
->on('refund', 'delivered', 'refunded', $log);
echo "State: " . $order->getState() . "\n";
$order->trigger('pay');
echo "State: " . $order->getState() . "\n";
$order->trigger('ship');
$order->trigger('deliver');
echo "State: " . $order->getState() . "\n";
echo "\nHistory: " . implode(' -> ', $order->getHistory()) . "\n";
// Try invalid transition
echo "\nTrying to ship a delivered order:\n";
try {
$order->trigger('ship');
} catch (LogicException $e) {
echo "Error: " . $e->getMessage() . "\n";
}
// Cancellation from early state
$order2 = new StateMachine('pending');
$order2->on('pay', 'pending', 'paid', $log)
->on('cancel', ['pending', 'paid'], 'cancelled', $log);
echo "\nCancelling from pending:\n";
$order2->trigger('cancel');
echo "State: " . $order2->getState() . "\n";
Comments
No comments yet
Be the first to share your thoughts!