Creating Finite State Machines in PHP 8.3
As developers, we’re constantly managing where entities are in some flow. Entities like blog posts, multi-step user registration, and even UI elements can exist in multiple states, and we’re responsible for making sure that they’re always in a valid state. If something unexpected happens in those flows, it can cause bugs, which can cause us to lose clients.
Thankfully, our industry has decades of research and thought into the best ways to manage these flows by using Finite-state Machines.
In this article, we’ll discuss what FSMs are and how to code them in PHP 8.3
What Are Finite-State Machines?
A Finite-state Machine (FSM) is an abstract model that can be in exactly one of a finite (see that word popping back up again) number of states at a time. FSMs move from one state to another based on some kind of input in what’s known as a transition.
When we define an FSM we do so by defining:
- The list of states
- The FSM’s initial state
- The transitions
In this article, we’re going to create an FSM for a blog post’s state through its publishing flow. A post will start in a “Draft” state. When the author is ready to have their editor review the article, it can be marked as “Ready for Review”. The editor can either send it back to “Draft” to get updates or schedule it for being published. Then, when the publish date occurs, the state automatically switches to “Published”.
- List of states: Draft, Ready for Review, Scheduled, Published
- Initial state: Draft
- Transitions
- “Draft” to “Ready for Review”
- “Ready for Review” to “Draft”
- “Ready for Review” to “Scheduled”
- “Scheduled” to “Published”
Before we do that we need to discuss Enumerations
Enumerations in PHP
PHP 8.1 added a bunch of new features, and Enumerations are by far my favorite. Enumerations, or enums for short, allow us to define a new structure much like a class but that can only hold a set of allowed values. They’re a powerful structure for modeling all kinds of domain logic, including finite-state machines.
We can create an enum
to model our published states using the code below. The case
keyword is used to distinguish what the valid values are for our enum
. We can define each of our states for our FSM as a different case
.
enum PublishedState {
case Draft;
case ReadyForReview;
case Scheduled;
case Published;
}
At a very basic level, we can assign an enum value to a property inside our class.
$this->state = PublishedState::Published;
var_dump($this->state); // enum(PublishedState::Published)
But it’s impossible to set it to an invalid case when we define the type of the property.
// PHP Fatal error: Uncaught TypeError: Cannot assign string to property BlogPost::$state of type PublishedState
$this->state = "Junk value";
It also has the bonus of allowing us to pass these as a parameter to a function so we’re always sending valid data.
function updateState(PublishedState $newState): void
{
// ...
}
Now, you might be thinking, couldn’t I just use a string or an integer to keep track of my values? The short answer is that you could, but by using enums, we’re given an extra level of type safety.
For example, without enums, we might use some class constants to keep track of our valid states.
class PublishedState {
public const DRAFT = "Draft";
public const READY_FOR_REVIEW = "Ready for Review";
public const SCHEDULED = "Scheduled";
public const PUBLISHED = "Published";
}
When we need to reference these states, we’re using strings, so our function declaration might look like the following:
public function updateState(string $newState): void
{
$this->state = $newState;
}
Now you might be saying no, no, no, that’s not going to work. You can pass any kind of value in for $newState
and then you’ll be in an invalid state. And you’re exactly right that it is a problem with this simple implementation. It’s really easy to pass ANY value to this function and perform invalid transitions.
// invalid state
$blogPost->updateState("PHP Architect");
// invalid transition
$blogPost->updateState(PublishedState::PUBLISHED);
By using our enum we automatically get two bonuses. The first is that because we have the parameter defined as a PublishedState
and not a string our IDE is going to give us a more helpful hint when we try to use the function. The second is that we’ll have a run-time check (and if we’re using a static code analysis tool like Psalm before then) to make sure we don’t pass an invalid enum value.
//result: Fatal error: Uncaught Error: Undefined constant PublishedState::ReadyForReviw
$post->updateState(PublishedState::ReadyFrReview);
The invalid transition part we’ll have to solve in the future because we have a couple of other topics to cover before we can solve that.
Representing States
Now one of the problems with creating FSMs using enums is that we need some way to represent our states outside of the enumerations. Ultimately, we’re going to need to store our state inside some kind of a persistence layer so we’re going to need to be able to have some kind of way to represent these states as a scaler value. There are two main ways that we can do this.
The first option is to use integers. Integers are an ideal solution because when we store them in a database they’re quick to insert, search, and update. Their downside is that it’s hard to quickly convert an integer into the state without doing some kind of lookup (or if you’ve memorized all the states in every state machine in your system).
The other option is to use strings. Strings are less than ideal from a database normalization standpoint and they’re slower to insert, search, and update. However, they’re easier to read so I’ll be using them for this article to make it easier for the reader.
To apply our chosen representations to our enums we can convert our enum to what’s called a “Backed Enum” because it’s “backed” up by a scaler value.
enum PublishedState:string {
case Draft = "Draft";
case ReadyForReview = "Ready For Review";
case Scheduled = "Scheduled";
case Published = "Published";
}
We can also use integer values if we’re so inclined.
enum PublishedState:int {
case Draft = 1;
case ReadyForReview = 2;
case Scheduled = 3;
case Published = 4;
}
See how much easier the string
version is to quickly parse.
PHP has some great logic built into its implementation to prevent us from shooting ourselves in the foot. When we’re using a Backed Enum we must create a unique scalar equivalent for all values. If we duplicate a value or skip a value we’ll get errors instead of it allowing us to continue. As someone who has accidentally created duplicate public const
s in the past, this is super helpful.
// results in:
// PHP Fatal error: Duplicate value in enum PublishedState for cases Draft and ReadyForReview
enum PublishedState:string {
case Draft = "Draft";
case ReadyForReview = "Draft";
case Scheduled = "Scheduled";
case Published = "Published";
}
// results in:
// PHP Fatal error: Case ReadyForReview of backed enum PublishedState must have a value
enum PublishedState:string {
case Draft = "Draft";
case ReadyForReview;
case Scheduled = "Scheduled";
case Published = "Published";
}
Now that we’ve defined our scalar equivalents we can grab it by using the value
property.
// this is "Scheduled"
echo PublishedState::Scheduled->value, PHP_EOL;
// also "Scheduled"
$state = PublishedState::Scheduled;
echo $state->value, PHP_EOL;
Scalar to Enum
When we need to get from the scalar value back to the enum we can use the from()
method. This method takes the string or integer value and converts it back to the enum.
$state = PublishedState::from("Scheduled");
var_dump($state); // enum(PublishedState::Scheduled)
If a value is passed that doesn’t match one of the defined values there will be an error.
// Fatal error: Uncaught ValueError: "junk" is not a valid backing value for enum "PublishedState"
$state = PublishedState::from("junk");
To make this safer PHP 8.1 gives us the tryFrom()
function that will return null
instead of throwing an error.
$state = PublishedState::tryFrom("junk");
var_dump($state); // null
Before you start writing a lot of code to handle all of these conversions from enum to database and back. A lot of Object Relational Managers have support, or are adding support, to automatically convert the database values to enums and back so check your documentation to see if it will help you do the heavy lifting.
Validating Transitions
Now that we’ve associated values with our enum
we can tackle the issue of validating our transitions. Now we could keep all of this logic inside our BlogPost
class but it would be better if we could locate it close to where we define our states inside of our PublishedState
enum. Thankfully, enum
s can contain methods.
They are also able to implement interfaces so we could have a dedicated FiniteStateMachine
interface to make sure our FSMs all have a common interface.
To start we’ll add a check to our updateState()
function to make sure we have a valid transition and throw an exception if it’s not.
public function updateState(PublishedState $newState): void
{
if (!$this->state->isValidTransition($newState)) {
throw new Exception("Unable to transition from {$this->state->value} to {$newState->value}");
}
$this->state = $newState;
}
Now we can add the function to PublishedState
to support this:
enum PublishedState:string {
public function isValidTransition(PublishedState $newState):bool {
$transitions = [
PublishedState::Draft->value => [
PublishedState::ReadyForReview,
],
PublishedState::ReadyForReview->value => [
PublishedState::Draft,
PublishedState::Scheduled,
],
PublishedState::Scheduled->value => [
PublishedState::Published
],
];
return in_array($newState, $transitions[$this->value]);
}
}
Now astute readers might have noticed we don’t have PublishedState::Scheduled
as a key in our transitions. That’s because it’s the terminal state and it has no valid transitions. You can add it to your implementation if you want to make sure you have that level of completeness but I just think it clutters up the function.
Finally Done!
There you go. After a little elbow grease and maybe a lot of head scratching we’ve arrived at a basic but fully functioning FSM written using native enums in PHP 8.1. Our code is fairly easy to read and maintain and we can easily create new enums for new FSM machines without too much trouble. We might want to create a trait for our setState()
function but that could be overkill.
What You Need to Know
- FSM machines allow us to define how an entity can flow through a system
- Use PHP’s Enum feature to build state machines
Leave a comment
Use the form below to leave a comment: