Stop Scattering String Statuses: Use PHP 8.1 Backed Enums
PHP 8.1 backed enums replace magic strings with type-safe domain values that still serialize to primitives for databases and APIs.
12 Jan 2026, 06:03 UTC

The problem with magic strings
You receive a bug report: an order is stuck in "shpped" because someone typed the status string wrong in a controller. The codebase defines `OrderStatus::SHIPPED = 'shipped'` in one place, but another developer wrote `'shipped'` directly in a repository query. Refactoring means searching the project and hoping you caught every typo.
String constants for domain values are easy to mistype, impossible to type-hint, and silent when a new status is added.
The useful takeaway: PHP 8.1 backed enums let you model statuses, roles, or tiers as real types while still storing and sending the same scalar strings your database and legacy API already expect.
What a backed enum actually is
A backed enum is a PHP 8.1 type where every case is tied to a single scalar value—an integer or a string. It implements `UnitEnum` and `BackedEnum`, so every case exposes a `$value` property and the class offers `from()` and `tryFrom()`.
`from('shipped')` returns the matching case, but throws a `ValueError` if the string does not exist. `tryFrom('shipped')` returns the case or `null` when nothing matches, which is safer for data coming from users, APIs, or database rows you do not fully control.
Worked example: order statuses
This enum replaces scattered strings:
namespace App\Domain\Order;
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
}
Because the enum is backed by `string`, you can pass it to methods that expect the domain type:
public function markShipped(Order $order): void
{
$order->status = OrderStatus::Shipped;
$this->orderRepository->save($order);
}
Legacy comparisons keep working through `$value`:
if ($order->status->value === 'shipped') {
// legacy code path
}
For exhaustive logic, use `match`. PHP will not let you forget a case:
$label = match ($order->status) {
OrderStatus::Pending => 'Awaiting fulfillment',
OrderStatus::Processing => 'Being packed',
OrderStatus::Shipped => 'With the carrier',
OrderStatus::Delivered => 'Delivered',
OrderStatus::Cancelled => 'Cancelled',
};
If you later add `OrderStatus::Returned`, every `match` expression over `OrderStatus` becomes incomplete. Static analysis tools such as PHPStan or Psalm flag the missing arm at analysis time, not when a customer notices a missing label.
Persistence, APIs, and legacy code
Your database column does not have to change. A `varchar` column can still hold `'shipped'`. The conversion happens at the boundary.
For Doctrine ORM, register a custom DBAL type that maps the enum class to a string column. A generic base type can read the `BackedEnum` interface and convert `->value` to and from the database scalar. Persist `OrderStatus::Shipped`, and the column stores `'shipped'`.
On the way out to an API, the enum serializes to its scalar value by default. If you need a label alongside the value, implement `JsonSerializable`:
#[\JsonSerializable]
public function jsonSerialize(): mixed
{
return [
'status' => $this->value,
'label' => ucfirst($this->value),
];
}
When parsing external input, prefer `tryFrom()`:
$status = OrderStatus::tryFrom($request->input('status'));
if ($status === null) {
throw new InvalidArgumentException('Unknown order status');
}
Trade-offs and limits
Backed enums cannot carry extra data per case. You cannot write `case Cancelled = 'cancelled' with string $reason`. If your state machine needs payloads—such as a cancellation reason paired with the cancelled status—you need a separate value object or the enum-plus-value-object pattern.
They also require PHP 8.1 or newer. Older runtimes cannot load the code, and polyfills do not give native type-checker integration.
Finally, every backed value must be unique. Two cases sharing the same string or integer produce a compile error.
Closing
Pick one domain concept that currently uses magic strings—order status, invoice state, user role—and convert it to a backed enum. Verify the PHP version is 8.1 or newer, use `tryFrom()` for anything entering your application, and run your static analyzer after adding a new case to catch incomplete `match` expressions. You keep the same database column and API shape, but gain type safety and refactoring confidence.
Practical check: run `php -v` in your project terminal to confirm the installed version. No special permissions are required, and the command only prints version information. Then create the enum above and assert `OrderStatus::from('shipped')->value === 'shipped'` in a test or REPL.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.