- 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 any PHP codebase that adopted enums when 8.1 dropped. You'll find the same shape: one enum file with eight cases, then a sibling OrderStatusHelper with getLabel(), getColor(), isFinal(), and a transitionsFor() method that takes the enum and returns an array. Three hundred lines of helper code wrapping a type that already has a class body.
Enums in PHP are full classes. They have a constructor (implicit), methods, constants, and they implement interfaces. The official docs cover this in two paragraphs and then everyone goes back to writing helpers.
Here are the six things you can put inside the enum that replace the helpers you keep writing around it.
The default toolkit and where it stops
A backed enum out of the box gives you three static-ish operations:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Refunded = 'refunded';
case Cancelled = 'cancelled';
}
OrderStatus::cases(); // all cases as array
OrderStatus::from('paid'); // throws if invalid
OrderStatus::tryFrom('weird'); // returns null if invalid
Most teams stop here. They take the enum value, push it through from() at the controller boundary, and reach for a helper class the moment they need a label or a transition rule. The helper class then drifts. New cases get added to the enum but the helper's match expression doesn't, and PHPStan doesn't catch it because the helper accepts OrderStatus and the match has a default arm.
Move the behaviour onto the enum and the drift stops being possible.
Method 1: tryFrom() with ?? throw for strict APIs
from() throws \ValueError with a message like "weird" is not a valid backing value for enum OrderStatus. That message leaks the enum name to clients and the exception type is generic.
tryFrom() returns null, which lets you throw your own typed exception:
$status = OrderStatus::tryFrom($request->input('status'))
?? throw new InvalidOrderStatusException(
sprintf('Unknown status: %s', $request->input('status'))
);
The ?? throw syntax landed in PHP 8.0 and pairs with tryFrom() perfectly. You get a typed exception, a controlled error message, and the type system knows $status is non-null after the line. PHPStan level 8 picks this up; level 6 catches the null branch but not the exception type.
A small thing. But you'll write tryFrom() ?? throw a hundred times in a real codebase and zero try/catch blocks around from(). Worth the muscle memory.
Method 2: Interface conformance
Enums implement interfaces. Most teams forget this. If you have a HasLabel interface that your UI layer reads, the enum can implement it directly:
interface HasLabel
{
public function label(): string;
}
interface HasColor
{
public function color(): string;
}
enum OrderStatus: string implements HasLabel, HasColor
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Refunded = 'refunded';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Paid',
self::Shipped => 'In transit',
self::Delivered => 'Delivered',
self::Refunded => 'Refunded',
self::Cancelled => 'Cancelled',
};
}
public function color(): string
{
return match ($this) {
self::Pending => 'amber',
self::Paid => 'blue',
self::Shipped => 'indigo',
self::Delivered => 'green',
self::Refunded => 'gray',
self::Cancelled => 'red',
};
}
}
The match is exhaustive. No default arm. Add a new case, PHP throws UnhandledMatchError at runtime, and PHPStan flags it at level 5 with Match expression does not handle remaining value. The helper class with its if/else chain never had that guarantee.
Filament, Livewire, and Laravel Nova all read this kind of interface (Filament ships Filament\Support\Contracts\HasLabel and HasColor out of the box). Your enum drops into the framework without an adapter.
Method 3: Methods on the enum itself
Beyond interface methods, add domain behaviour as plain methods:
enum OrderStatus: string implements HasLabel, HasColor
{
// ... cases and label()/color() from above
public function isFinal(): bool
{
return match ($this) {
self::Delivered, self::Refunded, self::Cancelled => true,
default => false,
};
}
public function isPaid(): bool
{
return match ($this) {
self::Paid, self::Shipped, self::Delivered => true,
default => false,
};
}
public function canBeRefunded(): bool
{
return $this->isPaid() && $this !== self::Refunded;
}
}
isFinal() uses default because the "final" set is small and stable. isPaid() does the same. Pick the style per method; exhaustive match for behaviour that needs to fail loud when cases are added, default for boolean predicates where new cases should default to false safely.
$order->status->canBeRefunded() reads as English. The same logic in a helper reads as OrderStatusHelper::canBeRefunded($order->status). Two more words and a static call.
Method 4: Const arrays for transition tables
State machines live in transition tables. The default place to put them is a service class, an array constant in a config file, or (the worst option) hardcoded inside a controller. Enums have constants too:
enum OrderStatus: string implements HasLabel, HasColor
{
// ... cases, label(), color(), isFinal()
private const TRANSITIONS = [
'pending' => ['paid', 'cancelled'],
'paid' => ['shipped', 'refunded'],
'shipped' => ['delivered', 'refunded'],
'delivered' => ['refunded'],
'refunded' => [],
'cancelled' => [],
];
public function allowedTransitions(): array
{
return array_map(
static fn (string $value) => self::from($value),
self::TRANSITIONS[$this->value] ?? []
);
}
public function canTransitionTo(self $target): bool
{
return in_array($target, $this->allowedTransitions(), true);
}
}
The constant is private. Nobody outside the enum touches the raw strings. The allowedTransitions() method hands back typed OrderStatus instances, not strings. canTransitionTo() takes another enum, not a string, so the caller can't pass garbage.
One gotcha worth flagging. PHP enum constants can't reference self::CaseName directly in the array definition. Case constants aren't constant expressions in const arrays until PHP 8.3 (the new in initializers RFC didn't cover this). On 8.1 and 8.2 you keep the string values in the constant and convert with self::from() at the access point, as above. On 8.3 you can switch to:
private const TRANSITIONS = [
'pending' => [self::Paid, self::Cancelled],
// ...
];
If your CI runs on multiple PHP versions, stick with the string-keyed version. It works everywhere.
Method 5: Enum-as-state-machine
Combine the transition table with a method that does the transition and you have a state machine inside the type. No StateMachine class, no WorkflowEngine.
enum OrderStatus: string implements HasLabel, HasColor
{
// ... everything above
public function transition(self $target): self
{
if (! $this->canTransitionTo($target)) {
throw new InvalidOrderStatusTransition($this, $target);
}
return $target;
}
}
Usage in a use case:
$order->status = $order->status->transition(OrderStatus::Paid);
Enums are immutable values, so transition() returns the target rather than mutating. The caller reassigns. That's fine. It mirrors how DateTimeImmutable works.
The exception:
final class InvalidOrderStatusTransition extends \DomainException
{
public function __construct(
public readonly OrderStatus $from,
public readonly OrderStatus $to,
) {
parent::__construct(sprintf(
'Cannot transition order from %s to %s',
$from->value,
$to->value,
));
}
}
Now your controller catches InvalidOrderStatusTransition, your domain rejects illegal moves at the type boundary, and the rule lives next to the type it constrains. The Symfony Workflow component does this with YAML configs and event listeners. For 90% of state machines, the enum version is a single file and zero framework coupling.
Method 6: Custom static factories
from() and tryFrom() aren't the only factories you're allowed to define. Add your own:
enum OrderStatus: string implements HasLabel, HasColor
{
// ... everything above
public static function initial(): self
{
return self::Pending;
}
public static function fromStripeStatus(string $stripeStatus): self
{
return match ($stripeStatus) {
'requires_payment_method', 'requires_confirmation' => self::Pending,
'succeeded' => self::Paid,
'canceled' => self::Cancelled,
default => throw new \InvalidArgumentException(
"Unknown Stripe status: {$stripeStatus}"
),
};
}
}
OrderStatus::initial() reads as intent: "start a new order." Compare to OrderStatus::Pending scattered across constructors; the named factory lets you change the initial state without grepping for the case.
fromStripeStatus() is an anti-corruption layer in two lines. Stripe's vocabulary doesn't leak into your domain. The mapping lives on the type that owns the concept.
The gotcha: JsonSerializable vs Symfony Serializer
Backed enums serialize to their backing value automatically when you use json_encode(). This is the part everyone gets right.
json_encode(['status' => OrderStatus::Paid]); // {"status":"paid"}
Implement JsonSerializable and that automatic behavior breaks. Look:
enum OrderStatus: string implements HasLabel, JsonSerializable
{
// ... cases
public function jsonSerialize(): array
{
return [
'value' => $this->value,
'label' => $this->label(),
];
}
}
json_encode(['status' => OrderStatus::Paid]);
// {"status":{"value":"paid","label":"Paid"}}
You wanted richer JSON, you got it. Now your frontend, which was expecting "paid", breaks. Worse: the Symfony Serializer ignores JsonSerializable entirely. It uses normalizers, and the default BackedEnumNormalizer serializes to the backing value regardless of what jsonSerialize() returns. So your test suite (which uses json_encode directly) shows the rich object, and your API (which goes through Symfony's serializer) shows the plain string. Two formats for the same data depending on which code path produced it.
The fix is to keep the enum simple, let it serialize to its backing value, and put rich representations in a dedicated DTO:
final readonly class OrderStatusView
{
public function __construct(
public string $value,
public string $label,
public string $color,
) {}
public static function from(OrderStatus $status): self
{
return new self(
value: $status->value,
label: $status->label(),
color: $status->color(),
);
}
}
The DTO carries the presentation concern. The enum stays a value. Serializers agree on the shape, whichever flavour your framework picked.
If you're stuck with JsonSerializable (legacy contracts, no Symfony Serializer in the project), at least be aware of the asymmetry. Document it in the enum's docblock with a note like // jsonSerialize is bypassed by Symfony's BackedEnumNormalizer; keep behaviour consistent if you ever add that package. Future-you will thank present-you.
What's left for a helper class
After moving labels, colors, predicates, transitions, factories, and state-machine behaviour onto the enum, the helper class has nothing left. That's the point.
The few things that don't belong on the enum:
- Database concerns. Joining a status to a count of orders is repository work, not enum work.
-
Localized labels. If
label()needs i18n, the enum exposes a key ('order.status.paid') and the translator resolves it. Don't import a translator into the enum. - Cross-aggregate logic. If "can ship" depends on the customer's credit standing, that's a domain service, not an enum method.
Everything else lives on the type. Two hundred lines of OrderStatusHelper collapse into one file that's the enum itself, and PHPStan stops missing the case you added last Tuesday.
What's the largest helper class you've watched grow up next to an enum in your codebase? Drop the ugliest method in the comments and we can argue about whether it belongs on the enum.
If this was useful
Enums are the easiest place in a PHP codebase to start moving behaviour out of helpers and onto the types that own it. The same instinct (put the rules next to the concept, not in a service class three layers away) is the thread that runs through clean and hexagonal architecture. If your codebase has outgrown its framework defaults and you're looking for the architectural layer underneath, that's what Decoupled PHP is about.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)