Skip to main content

Auto Transition

Another feature of this component is that depending on the state you are in and the data you pass to the state machine, it can decide what is the next state you can be.

Example: Stock Management​

Let's analyze the following states:

The transition is only possible if some conditions are satisfied. So, let's create the state, the possible transitions and its conditions.

Declaring the States and Transitions​

use ByJG\StateMachine\TransitionConditionInterface;

// The states, and nothing but the states:
enum Stock: string
{
case Start = '__VOID__';
case InStock = 'IN_STOCK';
case LastUnits = 'LAST_UNITS';
case OutOfStock = 'OUT_OF_STOCK';
case RequestedResupply = 'REQUESTED_RESUPPLY';
case Resupplied = 'RESUPPLIED';
case Unavailable = 'UNAVAILABLE';
}

// Transition conditions:
$inStockCondition = new class implements TransitionConditionInterface {
public function canTransition(?array $data): bool {
return $data["qty"] >= $data["min_stock"];
}
};

$lastUnitsCondition = new class implements TransitionConditionInterface {
public function canTransition(?array $data): bool {
return $data["qty"] > 0 && $data["qty"] < $data["min_stock"];
}
};

$outOfStockCondition = new class implements TransitionConditionInterface {
public function canTransition(?array $data): bool {
return $data["qty"] == 0;
}
};

// Transitions:
$transitionInStock = Transition::create(Stock::Start, Stock::InStock, $inStockCondition);
$transitionLastUnits = Transition::create(Stock::Start, Stock::LastUnits, $lastUnitsCondition);
$transitionOutOfStock = Transition::create(Stock::Start, Stock::OutOfStock, $outOfStockCondition);

// Create the Machine:
$stateMachine = FiniteStateMachine::createMachine(Stock::class)
->addTransition($transitionInStock)
->addTransition($transitionLastUnits)
->addTransition($transitionOutOfStock);

Using autoTransitionFrom​

The method autoTransitionFrom will check if is possible to do the transition with the actual data and to what state.

$stateMachine->autoTransitionFrom(Stock::Start, ["qty" => 10, "min_stock" => 20]); // returns LAST_UNITS
$stateMachine->autoTransitionFrom(Stock::Start, ["qty" => 30, "min_stock" => 20]); // returns IN_STOCK
$stateMachine->autoTransitionFrom(Stock::Start, ["qty" => 0, "min_stock" => 20]); // returns OUT_OF_STOCK

When auto transitioned, the state object returned has the ->getData() method with the data used to validate it.

Evaluation Order​

The transitions leaving the current state are evaluated in the order they were added to the machine, and by default the first one whose condition returns true wins. Evaluation stops there — the remaining conditions are not called.

Which of the matching transitions wins is a policy, and it can be replaced: see Choosing the Winner.

This matters when two conditions can be satisfied by the same data. In the example above the three conditions are mutually exclusive by construction (qty == 0, 0 < qty < min_stock, qty >= min_stock), so exactly one can match and the order is irrelevant. Conditions that look at different keys are much easier to overlap by accident:

$requested = new class implements TransitionConditionInterface {
public function canTransition(?array $data): bool {
return isset($data["invoice_number"]);
}
};

$unavailable = new class implements TransitionConditionInterface {
public function canTransition(?array $data): bool {
return isset($data["status"]);
}
};

Given ["invoice_number" => 10, "status" => "DNB"] both conditions are true, and the result is whichever transition was added first.

Choosing the Winner​

autoTransitionFrom() answers two questions: which transitions leaving the current state accept this data, and — when several do — which one is taken. The first question belongs to the conditions. The second is a policy, and it is an object implementing TransitionSelectorInterface:

interface TransitionSelectorInterface
{
/** @param iterable<Transition> $matching lazily evaluated, in declaration order */
public function select(State $from, iterable $matching, ?array $data): ?Transition;
}

Install one with selectWith(). Three come with the component:

selectorpolicy
Selector\FirstDeclaredthe first transition that accepts the data wins — the default
Selector\RejectAmbiguousexactly one may accept the data; more than one throws
Selector\HighestPrioritythe highest priority among those that accepted wins

Rejecting ambiguity​

If your conditions are meant to be mutually exclusive, Selector\RejectAmbiguous tells you when they are not, instead of silently handing back the first declaration:

use ByJG\StateMachine\Selector\RejectAmbiguous;

$stateMachine = FiniteStateMachine::createMachine(Stock::class)
->addTransition($transitionRequested)
->addTransition($transitionUnavailable)
->selectWith(new RejectAmbiguous());

// Throws TransitionException:
// "Ambiguous transition from LAST_UNITS: the data provided matches REQUESTED_RESUPPLY, UNAVAILABLE"
$stateMachine->autoTransitionFrom(Stock::LastUnits, ["invoice_number" => 10, "status" => "DNB"]);

throwErrorIfAmbiguousTransition() is a shorthand for exactly this, and stays available:

$stateMachine->throwErrorIfAmbiguousTransition(); // same as selectWith(new RejectAmbiguous())

Data matching exactly one transition still transitions normally, and data matching none still returns null (or throws, if throwErrorIfCannotTransition() is also enabled).

Ranking the moves​

Selector\HighestPriority replaces the tie-break instead of forbidding the tie. It exists so that the winner stops being "whichever line was typed first" — which matters most when the machine is built by fromDefinition(), because then the tie-break is the order of entries in a YAML file and reordering that file changes behaviour silently.

use ByJG\StateMachine\Selector\HighestPriority;

$stateMachine = FiniteStateMachine::createMachine(Stock::class)
->addTransition(Transition::create(Stock::LastUnits, Stock::Unavailable, $unavailable))
->addTransition(Transition::create(Stock::LastUnits, Stock::RequestedResupply, $requested)->withPriority(10))
->selectWith(new HighestPriority());

// REQUESTED_RESUPPLY, although it was declared second
$stateMachine->autoTransitionFrom(Stock::LastUnits, ["invoice_number" => 10, "status" => "DNB"]);

withPriority() returns a copy of the transition, so the original is untouched. Priority defaults to 0: a machine where nothing declares one has no ties to rank and behaves exactly as it did before. In a definition file it is one more optional key:

transitions:
- from: LAST_UNITS
to: REQUESTED_RESUPPLY
condition: 'App\Fsm\Requested'
priority: 10

Only the moves that accepted the data are ranked. A high-priority transition whose condition said no is not a candidate at all, so it cannot outrank anything.

Composing them​

The tie-breaker of HighestPriority is itself a selector, which is how the two policies combine:

$stateMachine->selectWith(new HighestPriority(new RejectAmbiguous()));

reads as "the highest priority wins, and two moves tied at the top is a mistake". Left alone, new HighestPriority() settles a tie by declaration order.

Writing your own​

A selector receives the state being left, the data, and the transitions that accepted it. The last one is a generator, evaluated one element at a time: a selector that stops at the first element never causes the remaining conditions to run, which is why the default stops at the first match. Drain it and you have paid for all of them.

use ByJG\StateMachine\TransitionSelectorInterface;

// The move going to whichever state the data asks for, ignoring declaration order entirely
class PreferredTarget implements TransitionSelectorInterface
{
public function select(State $from, iterable $matching, ?array $data): ?Transition
{
$fallback = null;

foreach ($matching as $transition) {
if ((string)$transition->getDesiredState() === strtoupper($data["prefer"] ?? "")) {
return $transition;
}
$fallback ??= $transition;
}

return $fallback;
}
}

Returning null means "no move": autoTransitionFrom() returns null, or throws under throwErrorIfCannotTransition(). A selector that refuses to choose because the data is ambiguous should throw a TransitionException saying so, the way RejectAmbiguous does, rather than return null.

info

A selector chooses between legal moves; it cannot invent one. It only ever sees transitions whose condition already returned true, and the machine throws if it hands back anything it was not offered. A buggy selector can pick the wrong move — it cannot produce a state reached through a condition that said no.

warning

Any selector that looks past the first match evaluates more of the conditions of the current state. That is affordable only because canTransition() is expected to be a pure test — keep side effects out of your conditions and put them in TransitionActionInterface instead.

Processing Transitions with Actions​

A transition can carry an action implementing TransitionActionInterface. It runs when you call process() on the state the transition produced:

use ByJG\StateMachine\TransitionActionInterface;

$notifyResupply = new class implements TransitionActionInterface {
public function execute(State $from, State $to, ?array $data): void {
echo "moved {$from} -> {$to} with " . json_encode($data);
}
};

$transition = Transition::create(Stock::LastUnits, Stock::RequestedResupply, $requestedCondition, $notifyResupply);
$resultState = $stateMachine->autoTransitionFrom(Stock::LastUnits, [... data ...]);

$repository->save($entity, $resultState); // commit the move first
$resultState->process(); // then run the transition action
tip

The data used to validate the transition is stored in the returned state and passed to execute() via process(). $to->getData() returns the same array.

Actions Belong to the Transition, Not the State​

This is the point of the design. Consider a state C reachable from both A and B, where each route must do something different:

$stateMachine = FiniteStateMachine::createMachine(Stock::class)
->addTransition(Transition::create(Stock::LastUnits, Stock::Resupplied, null, $viaAAction))
->addTransition(Transition::create(Stock::OutOfStock, Stock::Resupplied, null, $viaBAction));

$stateMachine->autoTransitionFrom(Stock::LastUnits, $data)->process(); // runs $viaAAction only
$stateMachine->autoTransitionFrom(Stock::OutOfStock, $data)->process(); // runs $viaBAction only

There is one C. No C_VIA_A/C_VIA_B split, and no if inside the action asking where it came from — although $from and $to are available if you want to log the route.

When the side effect is the same on every route into a state, declare it once with createMultiple(), which attaches one action to every inbound transition:

Transition::createMultiple(
[Stock::LastUnits, Stock::OutOfStock, Stock::RequestedResupply],
Stock::Resupplied,
$condition,
$arrivalAction
);
info

Because the action lives on the transition, $stateMachine->state(Stock::Resupplied)->process() does nothing: that state was not reached by anything, so there is no transition to run. Use transition() or autoTransitionFrom() to obtain a state that can be processed.

Performing a Transition Explicitly​

autoTransitionFrom() picks the target for you. When you already know both ends, use transition() — it validates the condition and returns the state reached, stamped with the transition it came through:

$next = $stateMachine->transition(Stock::LastUnits, Stock::RequestedResupply, ["invoice_number" => 10]);

if ($next !== null) {
$repository->save($entity, $next);
$next->process();
}

It returns null when the transition is not declared or its condition denies the move, and throws TransitionException instead if throwErrorIfCannotTransition() is enabled.