State Machine Transitions
The state machine transitions are pieces of PHP code that move the state machine from one state to another. Typically, such a transition implementation executes a few SQL queries, maybe send an e-mail, or calls a remote REST API. The transitions are a business logic layer of the application, and it encapsulates the model layer (i.e., data access objects or ORM).
The only way to invoke a transition is through a transition decorator.
A simple decorator we will use in this example is MethodTransitionsDecorator
class,
which calls a protected method to process a given transition.
The transition decorator uses TransitionGuard
to decide whether a given transition can be invoked.
The guard consults the state machine definition and evaluates access control rules.
If everything is fine, it allows the transition.
Having the transition implementation hidden in the protected methods ensures that there is no other way to access the business logic,
and thus, we can rely on the guard.
The implementation of our Task state machine is a rather simple set of SQL queries (using
Doctrine DBAL
Query Builder).
We extend the MethodTransitionsDecorator
and provide a protected method for each transition:
<?php // src/StateMachine/TaskTransitions.php
declare(strict_types = 1);
namespace App\StateMachine;
use Doctrine\DBAL\Connection;
use Smalldb\StateMachine\Transition\MethodTransitionsDecorator;
use Smalldb\StateMachine\Transition\TransitionEvent;
use Smalldb\StateMachine\Transition\TransitionGuard;
class TaskTransitions extends MethodTransitionsDecorator
{
private Connection $db;
public function __construct(TransitionGuard $guard, Connection $db)
{
parent::__construct($guard);
$this->db = $db;
}
protected function add(TransitionEvent $trEvent, Task $task, string $description)
{
$this->db->createQueryBuilder()->insert("Tasks")
->values(["id" => ":id", "description" => ":desc"])
->setParameter(":id", $task->getMachineId())
->setParameter(":desc", $description)
->execute();
$trEvent->setNewId($task->getMachineId() ?? $this->db->lastInsertId());
}
protected function editDescription(TransitionEvent $trEvent, Task $task, string $newDescription)
{
$this->db->createQueryBuilder()->update("Tasks")
->set("description", ":desc")
->where("id = :id")
->setParameter(":id", $task->getMachineId())
->setParameter(":desc", $newDescription)
->execute();
}
protected function markDone(TransitionEvent $trEvent, Task $task, \DateTimeImmutable $when)
{
$this->db->createQueryBuilder()->update("Tasks")
->set("completedAt", ":when")
->where("id = :id")
->setParameter(":id", $task->getMachineId())
->setParameter(":when", $when->format('Y-m-d H:i:s'))
->execute();
}
protected function markIncomplete(TransitionEvent $trEvent, Task $task)
{
$this->db->createQueryBuilder()->update("Tasks")
->set("completedAt", "NULL")
->where("id = :id")
->setParameter(":id", $task->getMachineId())
->execute();
}
protected function delete(TransitionEvent $trEvent, Task $task)
{
$this->db->createQueryBuilder()->delete("Tasks")
->where("id = :id")
->setParameter(":id", $task->getMachineId())
->execute();
}
}
A difference from the abstract methods in the Task
class is the two additional arguments in each method.
The TransitionEvent $trEvent
represents current transition invocation.
It allows us to perform some advanced actions,
for example, to notify the rest of the framework about the change of the state machine ID when we create a task.
The second argument, Task $task
, is a reference to the state machine on which we invoked the transition.
The TaskTransitions
class constructor receives the TransitionGuard $guard
and Connection $db
from the Symfony DI container.
In case the MethodTransitionsDecorator
would not fit our needs,
we could implement a custom transitions decorator by implementing the TransitionDecorator
interface.
We could also use, for example, Doctrine ORM instead of the plain SQL queries. The Smalldb framework does not enforce any particular way how to implement the transitions.