This library provides an implementation of a Finite State Machine (FSM) in PHP. The benefits of using an FSM architecture include:
- Simplified system behavior modeling: State machines help to represent and organize the behavior of a system in a structured and understandable manner.
- Ease of refactoring: Significant changes to system architecture can be done without affecting business logic
- Testability: State machines allow for easier testing of individual states and transitions, making it simpler to isolate and test specific system behaviors.
- Reduced complexity: By breaking down a complex system into smaller, manageable states, state machines can simplify the overall system design and make it easier to understand.
- Predictable behavior: State machines ensure that a system behaves consistently and predictably, as the transitions between states are explicitly defined.
- Documentation: State machines serve as a form of documentation, as they provide a visual/textual representation of the system's behavior and transitions
- Nested regions: One horizontal set of states is called a "region". However, each state can have any number of sub-regions, allowing both parallel states and hierarchical states
- Guards: Enable a given transition only if a predicate returns
true
. - Actions: Dispatch actions to the machine to achieve stateful behaviour. Only the action handlers corresponding to the active state will get called.
- Entry & Exit events: Attach arbitrary subscribers to state changes.
- Region & State context: Store data relevant to the current application state. Data can be scoped for an individual state - or shared with the entire region
- State inheritance: Since regions can be nested, each region can request specific data to be passed down from the parent region.
- Middleware: Before creating the final machine, your can augment your definitions with reusable middlewares.
Install this package via composer:
composer require noem/state-machine
This trivial example shows how a product may move through various states during processing:
<?php
require 'vendor/autoload.php';
use Noem\State\RegionBuilder;
$orderId = 12345;
// Define the state machine
$region = (new RegionBuilder())
// State configuration
->setStates('Checkout', 'Pending', 'Processing', 'Shipped', 'Delivered')
->markInitial('Checkout')
->markFinal('Delivered')
// Context for this machine instance
->setRegionContext(['orderId' => $orderId])
// Transitions
// In a real application, these inspect the $trigger and allow/deny the transition based on it
->pushTransition(from: 'Checkout', to: 'Pending', guard: fn(object $trigger): bool => true)
->pushTransition(from: 'Pending', to: 'Processing', guard: fn(object $trigger): bool => true)
->pushTransition(from: 'Processing', to: 'Shipped', guard: fn(object $trigger): bool => true)
->pushTransition(from: 'Shipped', to: 'Delivered', guard: fn(object $trigger): bool => true)
// Entry events
->onEnter(state: 'Pending', callback: function (object $trigger) {
echo "[{$trigger->timestamp->format("Y-m-d H:i:s")}] Order {$this->get('orderId')} has been received and is {$this}.\n";
})
->onEnter(state: 'Processing', callback: function (object $trigger) {
echo "[{$trigger->timestamp->format("Y-m-d H:i:s")}] Order {$this->get('orderId')} is now {$this}.\n";
})
->onEnter(state: 'Shipped', callback: function (object $trigger) {
echo "[{$trigger->timestamp->format("Y-m-d H:i:s")}] Order {$this->get('orderId')} is {$this}.\n";
})
->onEnter(state: 'Delivered', callback: function (object $trigger) {
echo "[{$trigger->timestamp->format("Y-m-d H:i:s")}] Order {$this->get('orderId')} is {$this}.\n";
})
// Build the state machine
->build();
// Simulate order processing
$time = DateTimeImmutable::createFromFormat('U.u', microtime(true));
$region->trigger((object)['timestamp' => $time]);
$region->trigger((object)['timestamp' => $time->add(DateInterval::createFromDateString('1 day'))]);
$region->trigger((object)['timestamp' => $time->add(DateInterval::createFromDateString('2 day'))]);
$region->trigger((object)['timestamp' => $time->add(DateInterval::createFromDateString('3 day'))]);
The RegionBuilder
in Noem State Machine is a class used for constructing and configuring finite state machines.
It allows developers to define states, transitions, guards, entry and exit events and actions
within a state machine, making it convenient for implementing stateful behavior in applications.
<?php
declare(strict_types=1);
use Noem\State\RegionBuilder;
$r = (new RegionBuilder())
// Define all possible states
->setStates('off', 'starting', 'on', 'error')
// if not called, will default to the first entry
->markInitial('off')
// if not called, will default to the last entry
->markFinal('error')
// Define a transition from one state to another
// <FROM> <TO> <PREDICATE>
->pushTransition('off', 'starting', fn(object $trigger):bool => true)
// no predicate means always true
->pushTransition('starting', 'on')
->pushTransition('on', 'error', function(\Throwable $exception){
echo 'Error: '. $exception->getMessage();
return true;
})
// Add a callback that runs whenever the specified state is entered
->onEnter('starting', function(object $trigger){
echo 'Starting application';
})
->onAction('on',function (object $trigger){
// TODO: Main business logic
echo $trigger->message;
})
->build(); // returns the actual Region object
while(!$r->isFinal()){
$r->trigger((object)['message'=>'hello world']);
}
Guard functions must return true
or false
.
If not provided, transitions are always allowed.
These functions can modify state context. They receive a trigger object as an argument.
Inherited regions allow for shared configurations across multiple states or regions.
initial
specifies the starting state of the machine.final
specifies the terminal state where no further transitions are allowed.
Just like PSR-14, the event system filters relevant event callbacks by their parameter type. This means you can -for example- only allow a transition when an Exception occurs, as seen above.
$r = new RegionBuilder();
$r->setStates('on', 'running', 'error')
->pushTransition('on', 'error', function(\Throwable $exception){
echo 'Error: '. $exception->getMessage();
return true;
})
;
However, it is also possible to use "named events", which provides a nice shortcut when using YAML definitions and greatly helps serializing application state. The syntax is a little more complex, though:
$r = new RegionBuilder();
$r->setStates('one', 'two', 'three')
->pushTransition('one', 'two', fn(#[Name('hello-world')] Event $event): bool => true)
;
Here, the Name
attribute works in tandem with the internal Event
interface that mandates a name.
If the region encounters a callback written like this, it will only consider the callback if:
- the
Event
type matches - and the
$event->name()
matches the value of#[Name()]
You can also load a state machine configuration from YAML. RegionLoader::fromYaml()
will provide
a RegionBuilder
which you can then modify further or start using right away.
Here is an example:
states:
- name: one
transitions:
- target: two
- name: two
regions:
states:
- name: one_one
transitions:
- target: one_two
- name: one_two
transitions:
- target: one_three
- name: one_three
transitions:
- target: three
- name: three
initial: one
final: three
This configuration can be loaded like this:
<?php
declare(strict_types=1);
use Noem\State\RegionLoader;
$yaml = file_get_contents('./path/to/machine.yaml');
$builder = (new RegionLoader())->fromYaml($yaml);
$builder->pushMiddleware(/** more on that in the next chapter */)->build();
It is easy to think of common & repetitive concerns that are portable from one machine to the other, for example
- Logging: Keeping track of any state change by adding a listener on each entry/exit event
- Exception handling: Adding an error state as well as a transition to it whenever an exception is caught
- Re/Store state: Serialize the machine context and restore it when it is reinitialized
For this scenario, RegionBuilder
offers support for middlewares that can make arbitrary changes
to a machine before it is built.
This example shows a simple logging middleware:
<?php
declare(strict_types=1);
use Noem\State\RegionBuilder;
$logs = [];
$middleware = function (RegionBuilder $builder, \Closure $next) use (&$logs) {
$builder->eachState(function (string $s) use ($builder, &$logs) {
$builder->onEnter($s, function (object $trigger) use (&$logs) {
$logs[] = "ENTER: $this";
});
$builder->onExit($s, function (object $trigger) use (&$logs) {
$logs[] = "EXIT: $this";
});
});
return $next($builder);
};
$region = (new RegionBuilder())
->setStates('foo', 'bar')
->pushTransition('foo', 'bar')
->pushMiddleware($middleware)
->build();