Taming Domain Model Magic Strings with PHP 8.1 Backed Enums
PHP 8.1 backed enums turn scattered magic strings into a closed set of type-safe values. Learn the migration path, a worked repository example, and the serialization trade-offs.
08 Jan 2026, 17:05 UTC

The bug report was short: a payment had vanished from the daily reconciliation report. The cause was equally short—a single missing letter. Somewhere in the integration code, an order status had been written as 'payd' instead of 'paid'. PHP accepted the string without complaint, the IDE offered no autocomplete, and the failure only surfaced when a reporting job queried for 'paid' and silently skipped the row. If your domain model still passes around plain strings and integer constants for states such as order status, user role, or shipment state, you are relying on developer discipline rather than the language to keep data correct.
PHP 8.1 backed enums replace that discipline with a closed set of named values that your editor, static analyzer, and runtime can all enforce. This article shows how to migrate scattered magic values into enum cases, where the approach pays off, and where it introduces new friction.
The hidden cost of magic values
A magic value is any literal—string, integer, or float—that carries domain meaning but no type safety. Class constants are a common first improvement:
class Order
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_SHIPPED = 'shipped';
}
Constants document intent, but they do not prevent a method from accepting 'shippd'. The signature setStatus(string $status) still permits any string. Refactoring becomes a grep exercise, and your IDE cannot reliably rename every usage because the value is just text.
What backed enums provide
A backed enum maps each case to a scalar value. Declaring one creates a real type:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
}
Once this type exists, you can type-hint parameters, return types, and properties as OrderStatus. The language now restricts values to the declared cases. Inside a match expression, every case must be handled or the expression throws \UnhandledMatchError at runtime; static analysis tools can flag missing branches before deployment.
Enums can also hold behavior. You can add methods and constants to the enum itself, keeping logic that belongs to the state next to the state definition:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
public function label(): string
{
return match($this) {
self::Paid => 'Payment received',
self::Shipped => 'On its way',
self::Pending => 'Awaiting payment',
};
}
}
A worked migration
Imagine an Order entity that currently stores status as a string and a repository that reads it from a database row.
Before:
final class Order
{
private string $status = 'pending';
public function markPaid(): void
{
$this->status = 'paid';
}
}
// Repository
$order->status = $row['status'];
After:
final class Order
{
private OrderStatus $status = OrderStatus::Pending;
public function markPaid(): void
{
$this->status = OrderStatus::Paid;
}
public function status(): OrderStatus
{
return $this->status;
}
}
// Repository: map database value to enum case
$status = OrderStatus::tryFrom($row['status']) ?? OrderStatus::Pending;
$order = new Order($status);
The repository uses tryFrom() because database columns can contain legacy or corrupted values. If you prefer to fail fast on invalid data, OrderStatus::from($row['status']) throws \ValueError when the string does not match a case. Either way, the mapping is explicit and testable.
Serialization requires the same care. Do not assume json_encode($order->status()) produces the scalar string you expect. Encode the backed value explicitly:
// API response
return json_encode([
'status' => $order->status()->value,
]);
To verify the migration locally, run these commands from your project root:
php -v— confirm you are on PHP 8.1 or later. If the version is lower, the enum syntax will not parse.php -l src/Domain/OrderStatus.php— lint the new file. Replace the path with your actual file location. You should seeNo syntax errors detected. If you see a parse error mentioningenum, your runtime is too old.composer dump-autoload— regenerate the autoloader if the enum lives in a namespaced directory. This is safe; it only rebuilds the class map.vendor/bin/phpstan analyse src/Domain --level=max— run static analysis. Replace the path with your domain directory. The check should report no missingmatcharms. If your codebase is not yet clean at level max, add a baseline file first so you can adopt the tool incrementally.
A quick unit test anchors the behavior:
public function testStatusMapping(): void
{
$this->assertSame(OrderStatus::Paid, OrderStatus::from('paid'));
$this->assertNull(OrderStatus::tryFrom('payd'));
}
Trade-offs and limitations
Backed enums are not a universal replacement for every constant.
| Concern | Impact |
|---|---|
| Version gate | Enums require PHP 8.1+. Your composer.json platform check and CI matrix must reflect this before you merge. |
| No runtime extension | You cannot add cases dynamically or subclass an enum. The set is fixed at compile time. |
| Persistence mapping | ORMs and raw SQL need explicit conversion between enum cases and stored scalars. Naive serialization can leak internal representation details. |
| Configuration values | Avoid enums for flags that non-developers must extend without a code deploy. A database-backed lookup table is a better fit there. |
The serialization point deserves emphasis. Because enums are objects, default JSON encoding behavior can surprise you. Always map to ->value or ->name explicitly and write a round-trip test that decodes the payload back into an enum case.
Where to start
You do not need to convert every constant in one pull request. Pick one domain concept—order status, payment method, or user role—where a typo would cause real damage. Introduce the enum, update the entity and repository, add the match exhaustiveness check to CI, and write the round-trip test. Once that pattern is proven, the next migration is mechanical.
The payoff is immediate: your editor autocompletes valid states, your static analyzer catches missing branches, and a typo like 'payd' becomes a compile-time error instead of a silent data corruption bug.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.