Replace String Constants with PHP 8.1 Backed Enums for Safer Domain Models
String constants in PHP are error‑prone. PHP 8.1’s backed enums give you type safety, clearer intent, and easier persistence. This blog walks through the syntax, shows a real‑world order‑status refactor, and explains the trade‑offs you’ll face.
20 May 2026, 23:52 UTC

Problem: The Classic String Constant Trap
Many PHP codebases model state with string or integer constants:
class Order
{
const STATUS_ACTIVE = 'active';
const STATUS_PAID = 'paid';
const STATUS_SHIPPED = 'shipped';
}
function publish(string $status) { /* … */ }
Because the constants are plain strings, you can easily pass an accidental value, misspell the constant, or change the string in one place and forget to update all callers. The compiler won’t help you; bugs surface only at runtime or in tests.
Thesis: Backed Enums are the Modern, Safer Alternative
PHP 8.1 introduced backed enums—special classes where each case is a singleton object backed by a scalar value. They provide:
- Compile‑time type safety in function signatures, property declarations, and return types.
- Built‑in validation via
from()(throws on invalid) andtryFrom()(returnsnullon invalid). - Direct mapping to database columns using the backing value, with minimal friction.
Understanding Backed Enums
Backed enums are declared with the enum keyword and a scalar type:
enum OrderStatus: string
{
case Active = 'active';
case Paid = 'paid';
case Shipped = 'shipped';
}
Each case is a constant object; you cannot instantiate the enum with new. The backing value is accessible via ->value. Enums can also declare methods and constants, but they cannot hold stateful properties or be extended.
Worked Example: Refactor an Order‑Status Flow
Suppose you have an existing function that checks a status string before publishing an order. We’ll replace the string constants with a backed enum.
- Define the enum with a helper method.
enum OrderStatus: string
{
case Active = 'active';
case Paid = 'paid';
case Shipped = 'shipped';
/**
* Return a human‑readable label.
*/
public function label(): string
{
return match ($this) {
self::Active => 'Active',
self::Paid => 'Paid',
self::Shipped => 'Shipped',
};
}
/**
* Define which transitions are allowed.
*/
public function canTransitionTo(self $next): bool
{
return match ($this) {
self::Active => $next === self::Paid,
self::Paid => $next === self::Shipped,
self::Shipped => false,
};
}
}
- Type‑hint the enum in your business logic.
function publish(OrderStatus $status): void
{
if ($status !== OrderStatus::Active) {
throw new RuntimeException('Only active orders can be published');
}
// … publish logic …
}
- Consume the enum from user input or database.
// From a form submission
$raw = $_POST['status']; // e.g., "paid"
$status = OrderStatus::tryFrom($raw);
if ($status === null) {
throw new RuntimeException('Invalid status');
}
publish($status);
- Persist the backing value.
// In a PDO statement
$stmt = $pdo->prepare('INSERT INTO orders (status) VALUES (?)');
$stmt->execute([$status->value]);
When reading the row back, use tryFrom() to hydrate the enum:
$row = $pdo->query('SELECT status FROM orders WHERE id = 1')->fetch(PDO::FETCH_ASSOC);
$orderStatus = OrderStatus::tryFrom($row['status']);
if ($orderStatus === null) {
throw new RuntimeException('Corrupt status in database');
}
Trade‑offs & Limitations
- PHP 8.1+ only. Projects that must support PHP 8.0 or older cannot use native enums.
- No extension or state. Enums cannot be subclassed or hold mutable properties. If you need polymorphic behavior, expose an interface instead.
- Error handling.
OrderStatus::from()throwsValueErroron invalid input; usetryFrom()for user data or legacy values. - ORM support. Some ORMs (e.g., Doctrine) added enum mapping in specific versions; verify compatibility before relying on automatic hydration.
How to Verify Your Setup
- Check PHP version. Run
php -v. You need 8.1 or later. - Test enum behavior. Write a small script:
enum TestEnum: int { case One = 1; case Two = 2; } var_dump(TestEnum::from(1)); // should be an object var_dump(TestEnum::tryFrom(3)); // should be null - Unit test type safety. Create a test that calls
publish('active')and expects aTypeErrorbecause the parameter expectsOrderStatus. - Database round‑trip test. Insert a row with
'paid'and retrieve it, asserting thatOrderStatus::tryFrom('paid')returns the correct enum case.
Conclusion
Backed enums give you a lightweight, type‑safe alternative to string constants. They enforce valid values at compile time, reduce boilerplate validation, and map cleanly to database columns. The trade‑offs—PHP 8.1 requirement and lack of extensibility—are manageable for most domain models. If your project already uses string constants for state, consider a gradual migration: start with a single enum, add type hints, and refactor the rest of the codebase over time.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.