- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open the orders table on any PHP service older than two years. There is a status column. It is a VARCHAR(32). Somewhere in the codebase a developer wrote $order->status = 'shipped', and somewhere else another developer wrote $order->status = 'Shipped', and a third wrote 'SHIPPED'. A migration script once renamed 'cancelled' to 'canceled' for the US market and missed three call sites. Two rows in production have status = 'pendng'. None of this was caught by the type system because there was no type. It was a string.
The bigger problem is not the typos. It is that nothing in the code stops a refund worker from flipping a cancelled order back to paid. The state machine that lives in the product manager's head never made it into the program: pending leads to paid leads to shipped leads to delivered; cancelled is terminal; refunds happen only after paid. It lives in tribal knowledge, in a Confluence page nobody updates, and in the bugs that get filed on Monday morning.
PHP 8.1 shipped backed enums in 2021. With strict types and a private const transition table on the enum itself, you can make invalid transitions a runtime error at the domain boundary and put the state machine where it belongs: next to the domain object it governs.
This post walks through the modeling end to end. Real PHP, no framework imports.
The shape of the problem
A typical Order entity in a framework-coupled codebase looks like this:
class Order
{
public string $status = 'pending';
public function markPaid(): void
{
$this->status = 'paid';
}
public function cancel(): void
{
$this->status = 'cancelled';
}
}
Three things are wrong, all of them invisible until production:
-
No type narrows the field.
$order->statusis any string. The IDE cannot autocomplete it. Static analysis cannot reason about it. -
No rule guards the transition.
markPaid()runs whether the order ispending,cancelled,delivered, orrefunded. A buggy webhook handler can revive a cancelled order. - The transition table lives in prose. The PM said "you can only cancel a pending or paid order, never after shipping." That sentence is in a Slack thread from 2024. It is not in the code.
A backed enum fixes the first item. A method on the enum fixes the second. A constant transition table makes the third self-documenting.
The enum, with behavior
Here is the full OrderStatus enum. It declares the six legal states, the transitions between them, and the method that enforces the rules. Strict types on top, non-negotiable.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
case Refunded = 'refunded';
/**
* @var array<value-of<self>, list<value-of<self>>>
*/
private const TRANSITIONS = [
'pending' => ['paid', 'cancelled'],
'paid' => ['shipped', 'cancelled', 'refunded'],
'shipped' => ['delivered'],
'delivered' => [],
'cancelled' => [],
'refunded' => [],
];
public function canTransitionTo(self $next): bool
{
return in_array(
$next->value,
self::TRANSITIONS[$this->value],
strict: true,
);
}
public function isTerminal(): bool
{
return self::TRANSITIONS[$this->value] === [];
}
/** @return list<self> */
public function nextStates(): array
{
return array_map(
static fn (string $v) => self::from($v),
self::TRANSITIONS[$this->value],
);
}
}
A few choices worth pointing at.
The transition table is private const. It is keyed by enum value (string) not by case, because PHP does not allow enum cases as array keys. The trade-off is small: lookups go through $this->value instead of $this itself. The win is that the whole table fits on one screen and is trivial to audit in a code review.
canTransitionTo() returns bool. It does not throw. That matters because callers will want to ask the question in two contexts: validation ("can the user click this button?") and enforcement ("am I about to do something illegal?"). Throwing from a predicate makes the validation case awkward. Let the caller pick.
isTerminal() and nextStates() are conveniences. They cost nothing to add and they remove the temptation for callers to hand-code transition logic somewhere else in the application.
The entity that uses it
The enum is half the story. The other half is the Order entity that holds the status field and exposes domain verbs (markPaid, ship, cancel) instead of a setter.
<?php
declare(strict_types=1);
namespace App\Domain\Order;
final class Order
{
private OrderStatus $status;
public function __construct(
public readonly string $id,
) {
$this->status = OrderStatus::Pending;
}
public function status(): OrderStatus
{
return $this->status;
}
public function markPaid(): void
{
$this->transitionTo(OrderStatus::Paid);
}
public function ship(): void
{
$this->transitionTo(OrderStatus::Shipped);
}
public function markDelivered(): void
{
$this->transitionTo(OrderStatus::Delivered);
}
public function cancel(): void
{
$this->transitionTo(OrderStatus::Cancelled);
}
public function refund(): void
{
$this->transitionTo(OrderStatus::Refunded);
}
private function transitionTo(OrderStatus $next): void
{
if (!$this->status->canTransitionTo($next)) {
throw new InvalidOrderTransition(
$this->id,
$this->status,
$next,
);
}
$this->status = $next;
}
}
The status field is private. The only way to change it is through one of the verbs, and every verb funnels through transitionTo(), which calls the enum's canTransitionTo(). There is no path in the type system that lets an external caller flip a delivered order back to pending.
The exception is its own type, sitting next to the entity:
<?php
declare(strict_types=1);
namespace App\Domain\Order;
final class InvalidOrderTransition extends \DomainException
{
public function __construct(
public readonly string $orderId,
public readonly OrderStatus $from,
public readonly OrderStatus $to,
) {
parent::__construct(sprintf(
'Order %s cannot transition from %s to %s',
$orderId,
$from->value,
$to->value,
));
}
}
A DomainException subclass with the three fields a caller will want — the order id, the source state, the target state. Logs and traces get structured fields. HTTP adapters can map this to a 409 Conflict without inspecting the message string.
Watching it fail loudly
The whole point of this shape is that bad transitions stop being silent. Run the following script against the code above:
<?php
require __DIR__ . '/vendor/autoload.php';
use App\Domain\Order\Order;
use App\Domain\Order\InvalidOrderTransition;
$order = new Order(id: 'ord_42');
$order->markPaid();
$order->ship();
$order->markDelivered();
try {
$order->cancel();
} catch (InvalidOrderTransition $e) {
echo $e->getMessage(), PHP_EOL;
}
Output:
Order ord_42 cannot transition from delivered to cancelled
The same pattern catches the subtler case — the refund worker that races a cancellation:
$order = new Order(id: 'ord_99');
$order->markPaid();
$order->cancel();
try {
$order->refund();
} catch (InvalidOrderTransition $e) {
echo $e->getMessage(), PHP_EOL;
}
Order ord_99 cannot transition from cancelled to refunded
The refund attempt is rejected at the domain boundary. The application layer never has to learn the rule. It does not branch on the status anywhere, because that knowledge belongs to the enum.
What this buys you in tests
Tests against this design are short because the surface is small.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use App\Domain\Order\Order;
use App\Domain\Order\OrderStatus;
use App\Domain\Order\InvalidOrderTransition;
final class OrderTransitionsTest extends TestCase
{
#[Test]
public function happy_path_runs_to_delivered(): void
{
$order = new Order(id: 'ord_1');
$order->markPaid();
$order->ship();
$order->markDelivered();
self::assertSame(
OrderStatus::Delivered,
$order->status(),
);
}
#[Test]
public function cancelling_a_delivered_order_is_rejected(): void
{
$order = new Order(id: 'ord_2');
$order->markPaid();
$order->ship();
$order->markDelivered();
$this->expectException(InvalidOrderTransition::class);
$order->cancel();
}
#[Test]
public function delivered_is_terminal(): void
{
self::assertTrue(OrderStatus::Delivered->isTerminal());
self::assertSame([], OrderStatus::Delivered->nextStates());
}
}
No fixtures, no fakes, no test container. The state machine is pure PHP. It does not touch the database, the queue, or the HTTP layer, so the tests run in milliseconds and every transition rule is exercised in code, not in a wiki.
Two things that bite if you skip them
Persistence round-trip. When the order comes back from the database, the status is a string. Doctrine, Eloquent, and a hand-rolled DBAL all do the same thing — call OrderStatus::from($row['status']). The from() method throws if the string is unknown, which is the behavior you want. If a migration sneaks 'pendng' past you, the entity refuses to hydrate and the bug surfaces immediately instead of poisoning every downstream consumer. Use tryFrom() only when you have a fallback plan, like importing legacy data with a quarantine flag.
Schema enforcement at the database. The enum guards transitions in code. The database does not know about them. A direct SQL update can still flip delivered to pending. If that matters for your operations team, add a CHECK constraint listing the legal status values and, where the database supports it, a row-level trigger that rejects forbidden transitions. PostgreSQL handles this with a trigger function; MySQL 8 supports CHECK constraints but not the dynamic transition check, so you either accept the gap or model transitions through stored procedures. The honest answer in most codebases: keep the enum strict, log every persistence write, and treat ad-hoc SQL as a privileged operation.
Where this fits in the bigger architecture
The enum-as-state-machine pattern is one example of a general move: putting business rules in the domain layer where the framework cannot route around them. The OrderStatus enum has no idea whether Laravel, Symfony, or a CLI script is calling it. It does not import Eloquent, Doctrine, or Illuminate\Support\Collection. It is pure PHP. Swap Laravel for Symfony tomorrow and the enum still compiles.
When the domain expert tells you about a new state, say partial-refund, there is exactly one place to add it: the OrderStatus enum, plus the row in the TRANSITIONS table that says which states can lead to it. The HTTP controller, the queue worker, the admin panel, and the reporting query all pick up the change for free because they all route through the same type.
The same shape extends to anything with a lifecycle: subscriptions, invoices, KYC checks, deployment pipelines. Pick the next one in your codebase that still lives as a VARCHAR, and move it.
If this was useful
The pattern above is one chapter of a larger argument — keep your business rules out of the framework, name them in the language of the domain, and let the type system carry the weight that prose used to. Decoupled PHP walks the same shape across orders, payments, queues, and the migration path from a Laravel-coupled service to an architecture that survives a framework swap.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)