PHP Backed Enums: Replacing String Constants with Type-Safe Domain Models
Migrate legacy string/integer constants to PHP 8.1+ backed enums for type-safe domain modeling. Covers validation with ::tryFrom(), exhaustive match expressions, and Doctrine ORM mapping.
02 Nov 2025, 16:20 UTC

The Problem with Scattered Constants
Most PHP codebases rely on arrays like ['draft' => 'Draft', 'published' => 'Published'] or class constants such as const STATUS_DRAFT = 'draft'. While functional, these patterns leak: a typo in a controller can write 'pubished' to the database, an API might return an unexpected string, and static analysis cannot detect a missing case in a switch statement. These issues often surface as runtime bugs in production.
PHP 8.1 introduced backed enums—enums where each case holds a scalar string or int value. They provide a single source of truth for allowed values, compile-time exhaustiveness checking when paired with match, and a clear validation boundary via ::from() and ::tryFrom(). This guide demonstrates how to migrate legacy status fields to backed enums, covering database mapping, JSON serialization, and practical trade-offs.
Defining a Backed Enum
A backed enum declares its backing type (either string or int) and assigns a value to each case:
enum OrderStatus: string {
case Draft = 'draft';
case Pending = 'pending';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
Each case is a singleton object (e.g., OrderStatus::Draft) that exposes its scalar value via the ->value property. Because only one instance of each case exists, identity comparison using === is reliable and efficient.
Worked Example: Migrating an Order Status Field
1. Validation Boundary for HTTP Input
Replace manual in_array() checks with the enum's built-in instantiation methods to ensure only valid values enter your domain:
// Run in Controller/Request Handler
// PHP 8.1+
$status = OrderStatus::tryFrom($request->get('status'));
if ($status === null) {
throw new InvalidArgumentException('Invalid status provided');
}
// $status is now a type-safe OrderStatus instance
The ::from() method throws a ValueError on invalid input, while ::tryFrom() returns null. Use ::tryFrom() when you need to handle the error gracefully with a custom response.
2. Exhaustive Matching in Business Logic
Using match with a concrete enum type allows static analysis tools like PHPStan (level 8+) to verify that every possible case is handled:
function nextTransition(OrderStatus $current): array {
return match ($current) {
OrderStatus::Draft => [OrderStatus::Pending],
OrderStatus::Pending => [OrderStatus::Shipped, OrderStatus::Cancelled],
OrderStatus::Shipped => [],
OrderStatus::Cancelled => [],
};
}
If a new case (e.g., case Returned) is added to the enum later, static analysis will flag this function as incomplete before the code is ever deployed.
3. Doctrine ORM Mapping (2.12+)
Modern Doctrine ORM versions support enums natively without requiring custom DBAL types. Annotate the property with the enumType attribute:
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Order {
#[ORM\Column(type: 'string', length: 20, enumType: OrderStatus::class)]
private OrderStatus $status = OrderStatus::Draft;
}
Doctrine automatically handles the conversion between the database string and the enum instance during hydration and persistence.
4. JSON Serialization
PHP 8.2+ implements JsonSerializable natively for backed enums, meaning json_encode($status) outputs the backed value (e.g., "draft"). For projects on PHP 8.1, use a trait to achieve the same result:
trait JsonBackedEnum {
public function jsonSerialize(): string|int {
return $this->value;
}
}
enum OrderStatus: string {
use JsonBackedEnum;
case Draft = 'draft';
// ...
}
Trade-offs and Limitations
- Backing Type Restrictions: Only
stringandintare supported. Enums cannot be backed by floats, booleans, or objects. Mixed-type legacy constants must be normalized to a single scalar type before migration. - Instantiation: Enum cases cannot be created via
new. Dependency injection containers must reference cases statically or use a factory pattern, which may require configuration changes. - Static Analysis Requirements: Exhaustiveness checking only works when the
matchsubject is typed as the specific enum class. Typing a variable asUnitEnumormixeddisables this safety check. - Version Floor: Backed enums require PHP 8.1; native JSON serialization requires PHP 8.2.
Adoption Checklist
- Identify a high-churn constant set (e.g., payment states or user roles).
- Create a backed enum mirroring the existing database values.
- Replace input validation with
::tryFrom()at the API/HTTP boundary. - Update business logic to use
matchexpressions and verify with PHPStan level 8. - Map the database column using
enumTypeand verify the schema. - Confirm JSON output remains consistent for API consumers.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.