Replacing String Status Codes with PHP Backed Enums
String status columns accept typos and invalid states. A PHP 8.1 backed enum makes the valid set explicit, with conversion at the edges and real trade-offs to weigh.
16 Jul 2026, 21:42 UTC

A status column that accepts anything
Most applications end up with a status field somewhere: orders.status, jobs.state, invoices.stage. It starts as a VARCHAR(20) and the valid values live in a developer's head, a wiki page, or a comment above a controller. Nothing stops a request from writing pendign, and nothing stops two code paths from disagreeing about whether the value is cancelled or canceled.
The usual fix is a pile of class constants plus a validation array. That helps, but constants are still just strings at runtime — a type hint cannot express "one of these four values," so every boundary has to re-validate by hand.
A backed enum, available since PHP 8.1, makes the set of valid states a type. This post covers what that buys you, where it costs you, and how to introduce one field at a time without a schema migration.
What a backed enum actually gives you
A backed enum is an enumeration where each case carries a scalar value — a string or an int. That backing value is what you persist; the case itself is what you pass around in code. The compiler and runtime enforce that only declared cases exist, so an invalid state becomes a type error rather than a data error.
Two conversion helpers matter in practice:
OrderStatus::from($value)returns the matching case, or throwsValueErrorif the value is not a declared backing value.OrderStatus::tryFrom($value)returns the case ornull, which is usually what you want at a request boundary.
There is also OrderStatus::cases(), which returns every case — useful for building a validation rule or a dropdown without maintaining a second list.
Worked example: an OrderStatus enum at the boundaries
The example below assumes PHP 8.1 or later. Run php -v in the target environment before refactoring; on 8.0 or earlier the syntax is a parse error, and no amount of framework support will help.
<?php
// Requires PHP 8.1+
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Payment received',
self::Shipped => 'On its way',
self::Cancelled => 'Cancelled',
};
}
}
Domain code now takes the enum, not a string, so an invalid state cannot reach it:
function labelFor(OrderStatus $status): string
{
return $status->label();
}
The conversion happens once, at the edge where untrusted input arrives:
$raw = $_POST['status'] ?? '';
$status = OrderStatus::tryFrom($raw);
if ($status === null) {
// Controlled validation failure: reject the request.
// Do not fall back to a default status silently.
}
Persistence is a deliberate step in both directions. Store $status->value when writing, and reconstruct with OrderStatus::from($row['status']) when reading. If you choose backing values that match the strings already in the column, no data migration is needed — the column keeps its existing contents and only the PHP side changes.
JSON is worth checking explicitly. A backed enum encodes to its backing value with json_encode(), while a pure (non-backed) enum cannot be JSON-encoded at all. If an API response shape matters to clients, confirm the output rather than assuming it.
Where the conversion should live
Keep from() and tryFrom() at the boundary — controllers, queue consumers, CLI input parsing — and let everything behind that boundary accept the enum type. If string literals reappear deep in the domain layer, the enum has been added but not adopted, and the original bug class survives.
Trade-offs and limitations
- Enums are not strings. Database layers, serializers, and legacy functions that expect a scalar need an explicit
->valueorfrom()call. Frameworks differ in whether they cast enums automatically; check your ORM's documentation instead of assuming. - Adding a case is a code change, not a data change. Cases are fixed at compile time and cannot be extended at runtime. If the set of states is genuinely open-ended or tenant-defined, an enum is the wrong tool — a lookup table is.
- Exhaustive
matchstatements are a feature with a sharp edge. Amatchwith nodefaultarm throwsUnhandledMatchErrorwhen a new case is added and not handled. That surfaces the omission loudly, but only when the code path runs — so a new case needs a test that exercises it. - Invalid input handling changes shape.
from()throws;tryFrom()returnsnull. Mixing them inconsistently produces either uncaught exceptions or silent defaults, both worse than the string version.
Introducing it without a big-bang refactor
Pick one status field that already causes confusion. Define the enum with backing values identical to the strings currently stored, replace the literals at the input and output boundaries, and leave the column alone. Reverting the commit is a safe rollback because the stored data never changed format.
Then verify, rather than trusting the refactor:
- Run
php -vand confirm 8.1 or later. - In a scratch script, call
from()with a valid value,tryFrom()with an invalid one, and printcases(). Confirm the invalid call returnsnulland the valid one returns the expected case. - Round-trip a record: write the enum's backing value, read it back, reconstruct with
from(), and assert the case matches. - Add a test that invalid input produces a controlled validation error rather than a fatal
ValueErroror a silently defaulted status.
The payoff is narrow but real: one field where an invalid state is now impossible to represent in code, and where the list of valid states has exactly one definition.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.