Choosing Between Pure and Backed PHP Enums for Domain Modeling
Learn when to use pure versus backed PHP enums, how to model domain states, persist them with Doctrine, and avoid common pitfalls.
05 Nov 2025, 11:29 UTC

Problem: Modeling finite states without leaking primitives
When you need to represent a fixed set of concepts—like order statuses, user roles, or payment methods—it’s tempting to use strings or integers directly. This approach spreads primitive values throughout your code, makes refactoring error‑prone, and prevents you from attaching behavior to the concepts themselves.
Thesis: PHP enums give you a type‑safe way to model domain states, and the choice between pure and backed enums hinges on where you need to persist or serialize the value.
PHP 8.1 introduced enumerations as classes that can hold methods, implement interfaces, and be used in match expressions. Pure enums have no scalar backing; backed enums are tied to an int or string value. Understanding the trade‑offs helps you decide which flavor fits each layer of your application.
Pure enums: encapsulation first
A pure enum defines cases without assigning a scalar value. The enum itself is the source of truth, and you cannot accidentally persist its raw representation.
enum OrderStatus
{
case Pending;
case Processing;
case Shipped;
case Cancelled;
public function canTransitionTo(self $next): bool
{
return match ($this) {
self::Pending => $next === self::Processing,
self::Processing => $next === self::Shipped || $next === self::Cancelled,
self::Shipped => $next === self::Cancelled,
self::Cancelled => false,
};
}
}
Because each case is a singleton, identity comparison (===) works reliably. You can add methods like canTransitionTo that live alongside the cases, keeping behavior close to the data.
Backed enums: persistence and serialization made easy
When you need to store the enum in a database column or send it over JSON, a backed enum removes the need for manual mapping. You declare the backing type and assign a unique scalar to each case.
enum PaymentMethod: string
{
case Card = 'card';
case BankTransfer = 'bank_transfer';
case PayPal = 'paypal';
}
The value property gives you the scalar directly:
echo PaymentMethod::Card->value; // outputs: card
This property is what Doctrine ORM or Laravel Eloquent will persist when you use a custom cast type:
#[Column(type: 'string', enumType: PaymentMethod::class)]
private PaymentMethod $method;
Running php bin/console doctrine:schema:update --force (Symfony) or php artisan migrate (Laravel) will create a VARCHAR column that holds the string backing.
Worked example: mixing both flavors in a single domain
Imagine an Order entity that needs a status (pure enum) for business rules and a payment method (backed enum) for storage and API exposure.
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column]
private OrderStatus $status; // pure enum, stored via a lookup table
#[ORM\Column(type: 'string', enumType: PaymentMethod::class)]
private PaymentMethod $paymentMethod; // backed enum, stored directly
public function place(): void
{
if ($this->status->canTransitionTo(OrderStatus::Processing)) {
$this->status = OrderStatus::Processing;
}
}
}
To persist the pure enum, you might create a simple lookup table order_statuses with rows pending, processing, etc., and map it via a Doctrine Entity or a custom type. The backed enum needs no extra table.
Trade‑offs and limitations
- Pure enums keep your domain free of primitives, but you must add a mapping layer for persistence, which adds a bit of boilerplate and an extra join if you store the status in a separate table.
- Backed enums** couple the enum to a scalar value. If you ever need to change the backing string (e.g., renaming
'bank_transfer'to'wire'), you must migrate existing data. Also, backed enums cannot have additional per‑case state beyond the scalar. - Both kinds are singletons, so you cannot attach mutable data to a case. If you need case‑specific attributes, consider a value object or a separate class that holds the enum and the extra data.
How to verify your choice
- For a backed enum, run
php -r "enum Status: string { case Draft = 'draft'; case Published = 'published'; } echo Status::Draft->value;"and confirm the output matches the declared scalar. - For Doctrine, add the
#[Column(...)]annotation as shown, then runphp bin/console doctrine:schema:update --dump-sqlto see the generated SQL without applying it. - Test exhaustiveness with PHPStan: write a match expression that handles all cases, add a new case, and run
php vendor/bin/phpstan analyse src --level=5. The analyzer should flag the non‑exhaustive match.
Actionable closing
Start by asking where the enum will leave your PHP process. If it stays in memory for business logic, a pure enum gives you clean encapsulation and a natural place for methods. If you need to write it to a column, send it over an API, or store it in a cache, pick a backed enum (string‑backed for readability) and let the ORM or serialization handle the conversion. Write a small prototype, run the verification steps above, and you’ll have confidence that the enum choice matches both your domain needs and your persistence strategy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.