State Machines

Every business process is already a state machine, whether you model it or leave it scattered across booleans.

A finite state machine is a model of behaviour in which a thing can only ever be in one of a fixed set of named states, and can only move between them along explicitly defined transitions. Nothing else is permitted: no in-between values, no two states held at once. That constraint is the whole point of a state machine, and it is what makes the model worth reaching for.

Every business entity you already work with behaves this way. An order, an invoice, a support ticket, a background job: each moves through a defined lifecycle whether or not anyone has named it. The only real choice is whether to model that lifecycle explicitly, or leave it scattered across booleans and if-statements, where an illegal state like "cancelled but shipped" can quietly slip through.

The formal definition is short, so it comes first. The theory is brief; the engineering is where it earns its place.


What a Finite State Machine Actually Is

Formally, a finite state machine is defined by five components. Automata theory writes it as a five-tuple, but the notation hides how little there is to it. Each part maps directly onto something you already reason about when you model an entity's lifecycle.

  • A finite set of states. Every value the entity can hold, named and enumerated.
  • An input alphabet. The set of inputs the machine reads. In application code these are the events the entity responds to.
  • A transition function. Given the current state and an input, it returns the next state.
  • A start state. The single state the machine begins in.
  • A set of accepting states. In automata theory, the states where the machine may legitimately halt.

In application code the input alphabet becomes the events an entity responds to, and the accepting states become its terminal states: an order that is fulfilled or cancelled, a ticket that is closed.

DFA, NFA, Mealy and Moore: what matters in application code

Three distinctions from the theory come up, and only three answers matter here. A deterministic finite automaton (DFA) has exactly one transition for each state-and-input pair; a nondeterministic one (NFA) may have several or none, though every NFA has an equivalent DFA. Application code wants determinism: one legal answer to a given state and event. Mealy machines attach output to transitions, Moore machines attach it to states, which in practice only decides where a side effect belongs. And "finite state machine" and "state machine" name the same idea in application work, because you enumerate the states yourself.

Every Business Process Is Already a State Machine

The finite state machine examples that reach a textbook are turnstiles and traffic lights. The ones that reach production are the order status column, the approval flow and the job lifecycle: each one a machine that nobody wrote down.

Take an order. It moves through draft, submitted, approved, fulfilled and cancelled, and the rest of this page reuses that example. The progression is the machine, whether or not the code ever names it.

Order

draft, submitted, approved, fulfilled, cancelled.

Invoice

draft, sent, part-paid, paid, void.

Support ticket

open, pending, resolved, closed.

Each list looks obvious once it is written down. In most codebases it is written down nowhere, scattered across booleans and status strings that any query can flip in any order. What a state machine adds is the prohibition: it forbids the combinations that must never occur, such as an order that is both cancelled and shipped. That constraint is not decoration bolted on later. The lifecycle is part of how you model the entity.

The Naive Version, and the Bug It Lets Through

The first version of the order lifecycle almost never arrives as a state machine. It arrives as a pile of booleans and a status string on the orders table: is_paid, is_shipped, is_cancelled, each flipped independently by whichever controller, webhook or queued job reaches it first. Nothing relates them. The payment handler sets one, the dispatch job sets another, the cancel endpoint sets a third, and none of them consults the others.

// Migration: three flags, no relationship between them
$table->boolean('is_paid')->default(false);
$table->boolean('is_shipped')->default(false);
$table->boolean('is_cancelled')->default(false);

// PaymentController, after a successful capture
if ($order->is_paid && !$order->is_shipped) {
    ShipOrder::dispatch($order);
}

// CancelController, a different file, a different developer
public function cancel(Order $order)
{
    $order->is_cancelled = true;   // never looks at is_shipped
    $order->save();
}

Three booleans give eight representable combinations. Only four describe an order that should exist: unpaid, paid, paid and shipped, and cancelled before dispatch. The remaining combinations are forbidden by nothing, so they will eventually be written: a race between the cancel endpoint and the shipping job, a manual UPDATE to unstick a stuck order, an overnight import script that sets the flags in the wrong sequence. Each illegal combination becomes a support ticket first and a data-cleanup job later.

The enum status column many teams reach for instead is the same failure in thinner disguise: the column names the states but constrains none of the moves between them. Any write can set any value, so the rules about which move is allowed live only in developers' heads and in the order they happened to write the if statements.

The bug class: illegal states are reachable because nothing forbids the transition, not because someone wrote the wrong value. The fix is not more careful developers. It is a model in which the illegal move cannot be expressed.

The legal moves do exist. They just live nowhere in particular, scattered across controllers, jobs and the occasional hand-written query, so nothing sits between an order and an invalid write.

The State Machine Pattern: One Transition Table as the Source of Truth

The move the state machine pattern makes is to turn the legal transitions into data rather than control flow. You enumerate the states, name the events that fire against them, and write the transition function down as a table the code consults at runtime. Everything else hangs off entries in that table: a guard decides whether a given transition may fire, and a side effect decides what happens once it does.

Current state submit approve fulfil cancel
draft submitted - - cancelled
submitted - approved - cancelled
approved - - fulfilled cancelled
fulfilled - - - -
cancelled - - - -

Every dash is a bug the naive version permitted, including cancel on a fulfilled order, the "cancelled but shipped" state that is now simply unrepresentable.

The empty cells are the point. A transition table is a specification of what must never happen as much as a description of what may, and each blank is a move the system can no longer make. A guard is a condition attached to a transition, not a new state: approving an order may require that payment has cleared, so the move from submitted to approved still exists in the table while the guard decides whether this particular attempt is allowed right now.

Events name the triggers, and that naming carries weight because the same event fired from two different states means two different things, and the table is where the ambiguity gets resolved. Side effects attach to the transition or to state entry, the Mealy and Moore split from earlier: stock is reserved when an order is approved and released when it is cancelled. Because each transition is a single named point rather than a scattered set of conditionals, every one of them is a natural thing to record in an audit trail.

The same table is also your test matrix, one test per cell: legal cells assert the move landed, blank cells assert the exception fired, and the whole grid doubles as a design artefact you can walk through with the business before writing any code. The equivalent picture is the UML state transition diagram, circles for states and arrows for transitions, which renders cleanly with Mermaid or PlantUML if you want it living in the repo. The Gang of Four State pattern is the object-oriented dress for the same idea.

Implementing a State Machine in Laravel with a Backed Enum

In PHP 8.1 and up the whole machine fits in one backed enum, and that enum becomes the single source of truth. The states are its cases, the transition table from the previous section is a method returning the legal next states, and the guard question (can this move happen) stays separate from the command that performs it. The table translates line for line.

enum OrderStatus: string
{
    case Draft = 'draft';
    case Submitted = 'submitted';
    case Approved = 'approved';
    case Fulfilled = 'fulfilled';
    case Cancelled = 'cancelled';

    /** @return list<self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::Draft     => [self::Submitted, self::Cancelled],
            self::Submitted => [self::Approved, self::Cancelled],
            self::Approved  => [self::Fulfilled, self::Cancelled],
            self::Fulfilled, self::Cancelled => [],
        };
    }

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

The model holds the state and owns the single write path:

public function transitionTo(OrderStatus $target): void
{
    if (! $this->status->canTransitionTo($target)) {
        throw new IllegalTransition("{$this->status->value} -> {$target->value}");
    }

    DB::transaction(function () use ($target) {
        $this->update(['status' => $target]);
        OrderStatusChanged::dispatch($this, $target);
    });
}

The enum casts on the model with 'status' => OrderStatus::class, so $order->status hands back a typed case rather than a string. The throw is the concrete answer to the cancelled-but-shipped bug: an illegal move raises an exception at the one point every write passes through, instead of persisting quietly. If you would rather not hand-roll it, Spatie's laravel-model-states and Symfony's Workflow component implement the same pattern with more ceremony, and XState is the equivalent standard in TypeScript.

Guards slot in one level up. The transition from submitted to approved exists unconditionally in the map; whether this attempt may fire right now is a separate question, so an approve() action checks that payment has cleared before it calls transitionTo(). Keeping the two apart leaves the map static and easy to test, while the conditions live in one named place each.

Firing side effects safely on a transition

For a single machine the rule is to commit the state change inside a transaction and dispatch side effects to a queued job after commit, using afterCommit so nothing fires against a row that later rolls back. Make each side effect idempotent, because queues retry. Entry and exit actions map straight onto this: on entering Cancelled, release the reserved stock, in a job that checks whether it has already run.

Hazard: dispatch inside the transaction rather than after it and a worker can pick up the job before the commit lands, decrementing stock against a state that never persisted or sending the same confirmation twice. afterCommit plus idempotency closes both.

Concurrent writers and optimistic locking are workflow-engine territory, covered on the parent page.

When a Flat Finite State Machine Sprawls: Statecharts

A flat machine holds up until an order tracks two independent dimensions at once. Payment moves through pending, authorised, captured, refunded; fulfilment moves through unpicked, picking, dispatched. Model both in one machine and you need a state for every combination: four times three is twelve, and each new dimension you add multiplies the count again. That is state explosion, and it makes the transition table unreadable long before the domain itself gets complicated.

Rule: A flat machine stops paying off the moment its states multiply across independent dimensions.

David Harel's statecharts (1987) answer this with two extensions. Hierarchy nests states so they share transitions: "any payment state can move to refunded" becomes one arrow, not four. Parallel states run orthogonal regions independently, so payment and fulfilment each keep their own small machine. Implementations vary (XState, or SCXML as the W3C standard), but the Harel statechart concept is not tied to any of them.

Nesting states tames combinatorial sprawl inside a single entity. When the sprawl is instead about long-running processes, people, and time, the answer changes, and that boundary is next.

When a State Machine Becomes a Workflow Engine

With the machine modelled, the payoff is concrete. Illegal states are no longer representable: the code path that once let an order sit cancelled and shipped has nowhere left to run. The lifecycle is written down in one reviewable place, and adding a status becomes one enum case and one row in the transition table rather than an archaeology project across three controllers.

A backed enum is the right size for most entity lifecycles. It stops being enough at a few nameable thresholds.

Long-running processes: instances live for weeks and have to survive deploys and restarts, not just a single request.
Human steps: approvals wait on people, with reminders and time-based escalation when nobody responds.
In-flight change: the process definition changes while hundreds of instances are mid-flow, so both versions must run side by side.
Non-developers edit the flow: the people who own the process want to see or adjust it visually, without a deploy.

When those arrive, the pattern does not change, the machinery around it does. Persistence, timers, versioning and monitoring are what a workflow engine adds on top of the same transition logic, and the parent page covers that shift, including the build versus buy call. The division of labour with the sibling pattern stays clean: a state machine answers which state you are in and what may happen next, while a business rules engine answers under what conditions a given move is allowed. Systems of any real size usually run both together.

Untangle your status logic

A first conversation is low-key: we walk your entity lifecycles and find where the illegal states are leaking in.

Start a conversation →
Graphic Swish