php[architect] logo

Want to check out an issue? Sign up to receive a special offer.

PHP 8.6 Makes State Machines Easy (Enums + Roles)

Posted by on October 28, 2026

If your PHP app has a status column, you already have a state machine, but if you’re like most developers, you haven’t written down its rules. Any code can set an episode to “published”, whether it’s still a rough draft or the person clicking the button lacks access. PHP will save it without a complaint.

A finite state machine fixes this by listing every state a record can be in and the allowed transitions between those states. Add role-based permissions on top and you can answer two questions: is this a legal move, and is this user allowed to make it?

In this video, we’ll build a PHP state machine for a podcast episode workflow, add an editor and admin permission layer, and use three new PHP 8.6 features along the way: readonly property defaults, #[\Override] on class constants, and __debugInfo() on enums.

Hello developers, and welcome to the PHP Architect Channel! I’m Scott Keck-Warren, and today we’re turning a loose status column into a state machine that knows who is allowed to move it. If you’re new here, we cover topics related to the PHP ecosystem. Hit subscribe so you don’t miss the next one.

This video builds on our previous What’s New in PHP 8.6 (link in the description) so watch that for an overview. Everything in this video is based on RC3, the most current version as of this recording, and the final release lands November 19th, 2026.

What Is a State Machine?

A state machine represents a fixed (finite) set of states a model can be in. We define the allowed transitions out of each state, and the model sits in one state at a time. A state with no transitions out is a terminal state.

A traffic light is a simple example. It can only ever have one light on at a time and they always go green to yellow to red and back to green. We never have yellow and green on at the same time and we can never jump from green to red.

For our episode example, we’re going to allow the following:

The “published” state is a terminal state because once it’s in the feed it should stay there.

The transitions are as follows:

From Can move to
idea draft
draft idea, edit
edit draft, scheduled
scheduled edit, published
published (none, terminal)

How Do You Build a State Machine with PHP Enums?

Backed enums are one of my favorite PHP 8.x features, and they map onto a state machine with one case per state:

enum State: string
{
    case Idea = "idea";
    case Draft = "draft";
    case Edit = "edit";
    case Scheduled = "scheduled";
    case Published = "published";

    public function next(): array
    {
        return match ($this) {
            self::Idea => [self::Draft],
            self::Draft => [self::Idea, self::Edit],
            self::Edit => [self::Draft, self::Scheduled],
            self::Scheduled => [self::Edit, self::Published],
            self::Published => [],
        };
    }

    public function canMoveTo(self $target): bool
    {
        return in_array($target, $this->next(), true);
    }
}

The match statement has no default, so if we add a sixth case and forget its transitions, PHP throws an UnhandledMatchError. That error shows up at runtime, maybe in production, but it stops an episode from landing in the wrong state.

The terminal state returns an empty array from next(), which makes it easy to spot.

This code works in PHP 8.1 and up and replaces scattered if ($status === "draft") checks with one place that defines every legal move.

How Do You Debug an Enum in PHP 8.6?

Before PHP 8.6, debugging an enum took extra steps because var_dump() shows only the case name:

// output: enum(State::Edit)
var_dump(State::Edit);

PHP 8.6 lets enums define the __debugInfo() magic method (from the Debuggable Enums RFC), so we can add more information to the output:

public function __debugInfo(): array
{
    return [
        "value" => $this->value,
        "next" => array_map(fn (self $s) => $s->value, $this->next()),
        "terminal" => $this->next() === [],
    ];
}

Now var_dump() answers “where am I and where can I go?”, and the same output shows up in our debuggers:

enum(State::Edit) (3) {
  ["value"]=>
  string(4) "edit"
  ["next"]=>
  array(2) {
    [0]=>
    string(5) "draft"
    [1]=>
    string(9) "scheduled"
  }
  ["terminal"]=>
  bool(false)
}

We’ll be back after a word from our partners.

How Do You Add Role-Based Permissions to a State Machine?

Valid transitions stop illegal jumps, but anyone can still make a legal one. Roles define who may touch which states, so an editor can’t publish an episode that isn’t ready.

Each Role decides whether it permits() a change based on this table:

Role LEVEL $states Can do
ViewOnly 0 (empty) nothing
Editor 1 idea, draft, edit move between idea, draft, edit
Admin 2 all five every transition

Our rule: a role may change state only if both the from-state and the to-state appear in its $states list.

The abstract Role class (PHP 8.6 only):

abstract class Role
{
    public const string NAME = "role";
    public const int LEVEL = 0;

    /** @var list<State> */
    public readonly array $states = [];

    public function permits(State $from, State $to): bool
    {
        return in_array($from, $this->states, true)
            && in_array($to, $this->states, true);
    }
}

PHP 8.6 Feature: Readonly Property Defaults

Our Role class only runs on PHP 8.6 because it uses readonly property defaults.

If you run this in a pre-PHP 8.6 environment, you’re going to get “Fatal error: Readonly property Role::$states cannot have default value”. PHP 8.1 introduced readonly properties without defaults, so the only place to set one was the constructor. PHP 8.6 lifts that restriction.

For you, this means cleaner and safer code. Before 8.6, we had to set the value in the constructor or drop readonly and let a piece of bad code widen the permissions.

ViewOnly inherits the empty default, so it can’t move anything:

final class ViewOnly extends Role {}

$role = new ViewOnly();
// Fatal error: Uncaught Error: Cannot modify protected(set) readonly property ViewOnly::$states from global scope
$role->states = [State::Idea, State::Draft, State::Edit];

PHP 8.6 Feature: #[\Override] on Class Constants

#[\Override] is another one of my favorite features. PHP 8.3 added the attribute to mark a method as an override, and PHP reports an error if no parent method exists. It makes renames safer because overridden logic can’t be ignored without warning.

PHP 8.6 expands this to class constants so we can mark our name and level as overrides:

final class Editor extends Role
{
    #[\Override]
    const string NAME = "editor";

    #[\Override]
    const int LEVEL = 1;

    public readonly array $states = [State::Idea, State::Draft, State::Edit];
}

Admin follows the same pattern with every state:

final class Admin extends Role
{
    #[\Override]
    const string NAME = "admin";

    #[\Override]
    const int LEVEL = 2;

    public readonly array $states = [State::Idea, State::Draft, State::Edit, State::Scheduled, State::Published];
}

If we later rename LEVEL to ID in the abstract class, but not the concrete classes, we’ll get an error:


abstract class Role
{
    // ...
    public const int ID = 0;
    // ...
}

final class Editor extends Role
{
    // ...
    #[\Override]
    const int LEVEL = 1;
    // ...
}

Fatal error: Editor::LEVEL has #[\Override] attribute, but no matching parent constant exists in php shell code on line 16

Without this any code that now reads the ID will get the default value when working with an Editor role. It might error, or it might go undetected until one of your users can’t access their information anymore.

How Do You Check Both the Transition and the Role?

Back to the state machine. Now that we have our roles, the Episode class checks whether the move is legal and whether the role may make it:

final class Episode
{
    public function __construct(public private(set) State $state = State::Idea) {}

    public function moveTo(State $requested, Role $role): bool
    {
        $current = $this->state;

        if (!$current->canMoveTo($requested)) {
            echo "Denied: {$current->value} cannot move to {$requested->value}; episode stays {$current->value}\n";
            return false;
        }

        if (!$role->permits($current, $requested)) {
            echo "Denied: " . $role::NAME . " may not move an episode from {$current->value} to {$requested->value}; episode stays {$current->value}\n";
            return false;
        }

        $this->state = $requested;
        echo "Moved: {$current->value} → {$requested->value}\n";
        return true;
    }
}

$episode = new Episode(State::Edit);
$episode->moveTo(State::Published, new Admin());  // illegal jump
$episode->moveTo(State::Scheduled, new Editor()); // legal, but not for editors
$episode->moveTo(State::Scheduled, new Admin());

Denied: edit cannot move to published; episode stays edit
Denied: editor may not move an episode from edit to scheduled; episode stays edit
Moved: edit → scheduled

Two reasons can deny a transition, so we have two if blocks. A combined check would leave the user guessing. Separate messages tell them whether the workflow forbids the move or whether they need someone with more access.

This example started as part of Damien Seguy’s https://github.com/dseguy/phpenomenal/ and you can see it at https://github.com/dseguy/phpenomenal/pull/3. My version stretches it further with clamp(), SortDirection, Time\Duration, and partial function application, which our What’s New in PHP 8.6 video covers.

PHP 8.6 Gotchas

PHP 8.6 ships November 19, 2026, so keep this code out of production until then. Tooling will lag too: PHPStan, Psalm, and Mago may flag enum __debugInfo(), readonly property defaults, and #[\Override] on constants until they ship 8.6 support.

If you’re converting an existing status column, check your data first. State::from("Draft") throws a ValueError because the backing values are lowercase, and so does any legacy value like "archived" that isn’t a case. Use State::tryFrom() during the migration to find those rows, then clean them up before switching to from().

What You Need To Know

  1. A state machine is a fixed set of states, the allowed transitions between them, one current state, and terminal states with no way out.
  2. A backed enum with next() and canMoveTo() replaces a loose string status and blocks illegal jumps like idea → published.
  3. Permissions are a second check: the move must be legal, and the role must list both the from-state and the to-state; report each denial with its own message.
  4. PHP 8.6 readonly property defaults and #[\Override] on constants let each role declare its permissions without a constructor and fail loud when a parent constant changes.
  5. Enum __debugInfo() shows the current state and its next moves in var_dump() and your debugger; PHP 8.6 ships November 19, 2026.

 

Leave a comment

Use the form below to leave a comment: