StateMachine.php

PHP

Path: src/Hooks/StateMachine.php

<?php

namespace mini\Hooks;

use Closure;
use LogicException;
use UnitEnum;

/**
 * A state machine which validates that states only transition to legal target states.
 *
 * Provides hooks for entering, exiting, entered, and exited states, allowing fine-grained
 * control over state transitions. Prevents invalid transitions and re-entrant state changes.
 *
 * ## Example Usage
 *
 * ```php
 * $machine = new StateMachine([
 *     [Phase::Bootstrap, Phase::Request],  // Bootstrap can only go to Request
 *     [Phase::Request],                    // Request is terminal
 * ]);
 *
 * $machine->onEnteringState(Phase::Request, function($old, $new) {
 *     echo "Entering request phase";
 * });
 *
 * $machine->trigger(Phase::Request);  // Transitions and fires hooks
 * ```
 *
 * @template TState of string|int|UnitEnum The state type
 * @package mini\Hooks
 */
class StateMachine extends Dispatcher {

    /**
     * Valid state transitions for the state machine.
     *
     * @var array<string|int, list<TState>>
     */
    protected readonly array $transitions;

    /**
     * The current state
     *
     * @var TState
     */
    protected string|int|UnitEnum $state;

    /**
     * State being transitioned to (null if not transitioning)
     *
     * Used to prevent re-entrant transitions.
     *
     * @var TState|null
     */
    protected null|string|int|UnitEnum $transitioningTo = null;

    /**
     * Subscribers to all state changes
     *
     * @var list<callable(TState, TState): void>
     */
    protected array $listeners = [];

    /**
     * Subscribers for entering a particular state
     *
     * @var array<string|int, list<callable(TState, TState): void>>
     */
    protected array $enteringListeners = [];

    /**
     * Subscribers for exiting a particular state
     *
     * @var array<string|int, list<callable(TState, TState): void>>
     */
    protected array $exitingListeners = [];

    /**
     * Subscribers for completed entering a particular state
     *
     * @var array<string|int, list<callable(TState, TState): void>>
     */
    protected array $enteredListeners = [];

    /**
     * Subscribers for completed exiting a particular state
     *
     * @var array<string|int, list<callable(TState, TState): void>>
     */
    protected array $exitedListeners = [];

    /**
     * Subscribers to get notified ONCE when the current state exits
     *
     * @var list<callable(TState, TState): void>
     */
    protected array $exitCurrentListeners = [];

    /**
     * Configure the states and valid transitions
     *
     * Transitions are defined as an array of arrays, where each sub-array represents
     * a state and its valid target states. The first element is the source state,
     * subsequent elements are states it can transition to. If there are no subsequent
     * elements, the state is terminal (no valid transitions).
     *
     * Examples:
     * ```php
     * // Simple two-state lifecycle
     * $machine = new StateMachine([
     *     [Phase::Bootstrap, Phase::Request],  // Bootstrap → Request
     *     [Phase::Request],                    // Request is terminal
     * ]);
     *
     * // More complex state machine
     * $machine = new StateMachine([
     *     ['idle', 'running', 'stopped'],    // idle → running OR stopped
     *     ['running', 'idle', 'stopped'],    // running → idle OR stopped
     *     ['stopped', 'idle'],               // stopped → idle (can restart)
     * ]);
     * ```
     *
     * Note: Enums cannot be used as array keys in PHP, hence the nested array format.
     *
     * @param array<int, list<TState>> $transitions State definitions with valid targets
     * @param string|null $description Optional description for debugging
     */
    public function __construct(array $transitions, ?string $description = null) {
        parent::__construct($description);

        $mappedTransitions = [];
        foreach ($transitions as $transition) {
            $mappedTransitions[self::scalarState($transition[0])] = $transition;
        }
        $this->transitions = $mappedTransitions;

        // Set initial state to first state in first transition
        foreach ($transitions as $trans) {
            $this->state = $trans[0];
            break;
        }
    }

    /**
     * Return the current state
     *
     * @return TState
     */
    public function getCurrentState(): string|int|UnitEnum {
        return $this->state;
    }

    /**
     * String representation of current state
     *
     * @return string
     */
    public function __toString(): string {
        return (string)self::scalarState($this->state);
    }

    /**
     * Transition to a new state
     *
     * Validates the transition is legal, then fires hooks in this order:
     * 1. exitCurrent listeners
     * 2. exiting listeners for old state
     * 3. global listeners
     * 4. entering listeners for new state
     * 5. Changes state
     * 6. exited listeners for old state
     * 7. entered listeners for new state
     *
     * @param TState $targetState The state to transition to
     * @throws LogicException If transition is invalid or already transitioning
     */
    public function trigger(string|int|UnitEnum $targetState): void {
        try {
            $previousState = $this->state;

            $this->assertStateExists($targetState);
            $this->assertNotInTransition();
            $this->assertValidTargetState($targetState);

            $this->transitioningTo = $targetState;

            // Fire exitCurrent listeners (one-time listeners)
            $exitCurrentListeners = $this->exitCurrentListeners;
            $this->exitCurrentListeners = [];
            $this->invokeAll($exitCurrentListeners, $previousState, $targetState);

            // Fire state transition hooks
            $this->invokeAll($this->exitingListeners[self::scalarState($this->state)] ?? [], $previousState, $targetState);
            $this->invokeAll($this->listeners ?? [], $previousState, $targetState);
            $this->invokeAll($this->enteringListeners[self::scalarState($this->transitioningTo)] ?? [], $previousState, $targetState);

            // Perform the actual state change
            $this->state = $this->transitioningTo;

            // Fire completion hooks
            $this->invokeAll($this->exitedListeners[self::scalarState($previousState)] ?? [], $previousState, $this->state);
            $this->invokeAll($this->enteredListeners[self::scalarState($this->state)] ?? [], $previousState, $this->state);
        } finally {
            $this->transitioningTo = null;
        }
    }

    /**
     * Register a listener that fires once when the current state exits
     *
     * @param callable(TState, TState): void $listener
     */
    public function onExitCurrentState(Closure $listener): void {
        $this->exitCurrentListeners[] = $listener;
    }

    /**
     * Subscribe to when a particular state is about to be entered
     *
     * @param TState|list<TState> $targetStates
     * @param callable(TState, TState): void $listener
     */
    public function onEnteringState(string|int|UnitEnum|array $targetStates, Closure $listener): void {
        foreach ($this->filterStatesArgument($targetStates) as $state) {
            $this->enteringListeners[self::scalarState($state)][] = $listener;
        }
    }

    /**
     * Subscribe to when a particular state has been entered
     *
     * @param TState|list<TState> $targetStates
     * @param callable(TState, TState): void $listener
     */
    public function onEnteredState(string|int|UnitEnum|array $targetStates, Closure $listener): void {
        foreach ($this->filterStatesArgument($targetStates) as $state) {
            $this->enteredListeners[self::scalarState($state)][] = $listener;
        }
    }

    /**
     * Subscribe to when a particular state is about to be exited
     *
     * @param TState|list<TState> $targetStates
     * @param callable(TState, TState): void $listener
     */
    public function onExitingState(string|int|UnitEnum|array $targetStates, Closure $listener): void {
        foreach ($this->filterStatesArgument($targetStates) as $state) {
            $this->exitingListeners[self::scalarState($state)][] = $listener;
        }
    }

    /**
     * Subscribe to when a particular state has been exited
     *
     * @param TState|list<TState> $targetStates
     * @param callable(TState, TState): void $listener
     */
    public function onExitedState(string|int|UnitEnum|array $targetStates, Closure $listener): void {
        foreach ($this->filterStatesArgument($targetStates) as $state) {
            $this->exitedListeners[self::scalarState($state)][] = $listener;
        }
    }

    /**
     * Subscribe to get notified about all state transitions
     *
     * @param callable(TState, TState): void ...$listeners
     */
    public function listen(Closure ...$listeners): void {
        foreach ($listeners as $listener) {
            $this->listeners[] = $listener;
        }
    }

    /**
     * Unsubscribe from all state transition events
     *
     * Removes ALL subscriptions for the provided closures.
     *
     * @param callable(TState, TState): void ...$listeners
     */
    public function off(Closure ...$listeners): void {
        self::filterArrays($listeners,
            $this->listeners,
            $this->enteringListeners,
            $this->exitingListeners,
            $this->enteredListeners,
            $this->exitedListeners,
            $this->exitCurrentListeners,
        );
    }

    /**
     * Assert we're not currently transitioning
     *
     * @throws LogicException If already transitioning
     */
    protected function assertNotInTransition(): void {
        if ($this->transitioningTo !== null) {
            throw new LogicException(
                "Already transitioning to state `" . self::scalarState($this->transitioningTo) .
                "`, can't begin another transition yet."
            );
        }
    }

    /**
     * Assert that the provided states exist in the state machine
     *
     * @param TState ...$states
     * @throws LogicException If any state is unknown
     */
    protected function assertStateExists(string|int|UnitEnum ...$states): void {
        foreach ($states as $state) {
            if (!isset($this->transitions[self::scalarState($state)])) {
                throw new LogicException("Unknown state `" . self::scalarState($state) . "`");
            }
        }
    }

    /**
     * Assert that the provided state is a valid target from current state
     *
     * @param TState $state
     * @throws LogicException If transition is invalid
     */
    protected function assertValidTargetState(string|int|UnitEnum $state): void {
        $this->assertStateExists($state);
        $targets = $this->transitions[self::scalarState($this->state)];

        // Check if target state is in the list (skip first element which is the source state)
        if (\in_array($state, \array_slice($targets, 1), true)) {
            return;
        }

        // Build error message
        if (count($targets) === 1) {
            $tail = 'there are no valid future states (terminal state)';
        } else {
            $validStates = array_map(
                fn($s) => '`' . self::scalarState($s) . '`',
                \array_slice($targets, 1)
            );
            $tail = 'valid target states are ' . implode(', ', $validStates);
        }

        throw new LogicException(
            "Illegal transition from `" . self::scalarState($this->state) .
            "` to `" . self::scalarState($state) . "`: $tail"
        );
    }

    /**
     * Normalize state arguments (handles arrays, recursion)
     *
     * @param TState|list<TState> ...$states
     * @return list<TState>
     */
    protected function filterStatesArgument(string|int|UnitEnum|array ...$states): array {
        $result = [];
        foreach ($states as $state) {
            if (\is_array($state)) {
                foreach (self::filterStatesArgument(...$state) as $s) {
                    $result[self::scalarState($s)] = $s;
                }
            } else {
                $result[self::scalarState($state)] = $state;
            }
        }
        $this->assertStateExists(...$result);
        return \array_values($result);
    }

    /**
     * Convert state to scalar value for array keys
     *
     * UnitEnums use their name, others pass through.
     *
     * @param TState $state
     * @return string|int
     */
    private static function scalarState(string|int|UnitEnum $state): string|int {
        if ($state instanceof UnitEnum) {
            return $state->name;
        }
        return $state;
    }
}