PHP Enums: Backed vs. Pure Enums Compared
AI generated
<?php
8.4
PHP · Enums · Backed & Pure
PHP Enums: Backed vs. Pure Enums
when each variant is the right choice

Class constants and magic strings have not been a necessary evil since PHP 8.1. PHP Enums bring genuine type safety to finite sets of values: Pure Enums for purely internal states, Backed Enums for persistence and API compatibility, plus methods, interfaces and safe conversion with from() and tryFrom().

14 min read Enums · Backed & Pure · match · Interfaces PHP 8.1+

1. Why Enums? The Problem with Class Constants and Magic Strings

Before PHP 8.1, a pattern emerged for finite sets of values such as order statuses, roles, or payment methods that works at first glance but is structurally error-prone: class constants. A class like OrderStatus would define const ACTIVE = 'active';, const CANCELLED = 'cancelled';, and so on. The problem: the return type of a method that delivers such a status is string at best, mixed at worst. PHP cannot check at compile time whether 'aktiv' was passed instead of 'active', whether there is a typo in the constant, or whether a valid value was meant at all. This exact lack of type safety is the starting point that PHP Enums solve.

Magic strings are the second, closely related problem. If a status is passed directly as a string literal through the codebase instead of through a named constant, there is no safety net left at all. A typo like 'shiped' instead of 'shipped' is caught neither by the IDE nor by PHPStan, because both are syntactically valid strings. Only at runtime, often only in production, does it become apparent that a comparison never returns true. Enums close this gap, because an enum case is its own named type and does not accept an arbitrary string in its place.

The third aspect is completeness: class constants give no indication of which values even exist without reading the class definition or resorting to reflection. PHP Enums ship with cases(), a built-in way to list all defined variants at runtime, which is a direct win for validation, form generation, and test coverage. This makes Enums not syntactic sugar, but a distinct language mechanism with clear guarantees that class constants structurally cannot provide.

2. Pure Enums: Syntax and Basics

A Pure Enum is the simplest form of PHP Enums: a named set of cases with no underlying scalar value. The syntax mirrors classes but uses the enum keyword instead of class, and each case is declared with case Name;. Every case is a singleton instance of the enum type at runtime. Concretely, that means two references to Status::Active are always identical, so a comparison with === is both correct and fast, because PHP compares object identities rather than object values.

Important for the mental model: a Pure Enum has no implicit value that could be serialized or written to a database. There is no ->value property. Anyone who tries to apply json_encode() directly to a Pure enum case gets an empty object, not a meaningful string. That is intentional: Pure Enums are meant for purely internal states where the identity of the case matters, not its external representation. This exact property makes Pure Enums the right tool for state machines, internal flags, or strategy selection inside a class.

Because every case is a singleton instance, enum cases are inherently immutable: there is no constructor that can be called from the outside, and no way to change a case's state afterwards, similar to readonly properties, only anchored structurally in the language rather than through a single keyword. This also removes the classic concern about accidental mutation of a shared object that is familiar from ordinary classes.


declare(strict_types=1);

// Pure enum: no backing value, cases are compared by identity
enum OrderStatus
{
    case Pending;
    case Processing;
    case Shipped;
    case Delivered;
    case Cancelled;
}

function describeStatus(OrderStatus $status): string
{
    // Identity comparison works because every case is a singleton
    return match (true) {
        $status === OrderStatus::Pending => 'Awaiting confirmation',
        $status === OrderStatus::Cancelled => 'No further action possible',
        default => 'In progress',
    };
}

$current = OrderStatus::Processing;
var_dump($current === OrderStatus::Processing); // bool(true)

3. Backed Enums: int and string as the Backing Type

Backed Enums extend the core idea of Enums with a scalar value per case. The declaration enum Status: string { case Active = 'active'; } binds a concrete string or int value to each case, accessible through the read-only ->value property. This binding is strictly consistent: once an enum is declared Backed, every single case must receive a value of the same scalar type, mixed types within one enum are not allowed. The compiler enforces this already at parse time.

The practical benefit lies exactly where Pure Enums reach their limit: persistence and serialization. A Backed Enum can be stored in a database column without loss, because ->value yields exactly the scalar value that also lives in the column. For a JSON API, json_encode() on a Backed Enum does the expected thing: the backing value is serialized automatically, not some internal object. These two properties make Backed Enums the natural choice whenever values cross system boundaries, for example request payloads, response bodies, or rows from a relational table.

One important detail: the backing value is not meant to be passed around the code as a comparison value. Application code should keep working with the enum type itself, for instance Status::Active, and only unwrap ->value at the edges of the system, where persistence or serialization actually happens. Anyone who instead compares raw strings all the time gives up a large part of the type-safety gain of PHP Enums.


declare(strict_types=1);

// Backed enum: every case carries a scalar value of the same type
enum OrderStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
}

// Persisting to a database column
$stmt = $pdo->prepare('UPDATE sales_order SET status = :status WHERE entity_id = :id');
$stmt->execute([
    'status' => OrderStatus::Shipped->value, // 'shipped'
    'id' => 4711,
]);

// Serialization to JSON: the backing value is used automatically
$payload = ['orderId' => 4711, 'status' => OrderStatus::Shipped];
echo json_encode($payload); // {"orderId":4711,"status":"shipped"}

4. Methods on Enums: Encapsulating Behavior

One aspect that clearly sets PHP Enums apart from classic constant sets is the ability to define instance methods. An enum can have methods just like a class, methods that access $this and can therefore react to the current case. A method label() can return a human-readable, translatable name for each case, a method color() a hex value for UI rendering, a method isFinal() a boolean stating whether a status is a terminal state. The decisive advantage: this logic lives directly with the data type instead of being scattered across controllers, templates, or helper classes.

Inside a method, match ($this) is the common pattern for implementing different behavior per case, because $this inside an enum method is always the concrete calling case. That allows complex branching, which would otherwise end up in a separate service or repeated if cascades, to be bundled directly and exhaustively inside the enum itself. For Backed Enums, methods can additionally be combined with ->value, for example to translate a database value into a formatted display without the caller needing to know the translation logic itself.

Methods on PHP Enums can also return more complex types than scalars, for example an array of allowed next states in a state machine, or a DTO with extra information about the case. This makes enums a natural place for so-called "smart enums" that do not just represent a value but encapsulate the complete domain knowledge about that value, a technique that was already rebuilt with class implementations in domain-driven-design contexts before PHP 8.1 and that native Enums today make considerably simpler.

5. Implementing Interfaces

An enum in PHP can implement one or more interfaces, using exactly the same syntax as a class: enum Status: string implements HasLabel, HasColor. This opens up genuine polymorphism for PHP Enums: a function can require the interface as its parameter type instead of a concrete enum type, and it then works with any enum that satisfies that interface. This is especially valuable when several independent enums exist in an application that all share a common capability such as "has a label" or "has a priority" but represent entirely different sets of values.

An important boundary: enums cannot extend a class and cannot inherit from another enum, neither Pure nor Backed. There is no classic inheritance hierarchy between enums. This is a deliberate design decision of the language, because inheritance makes little conceptual sense for a finite, closed set of values: a derived enum would either have to add cases, which would violate the "closed world" of the base enum, or restrict cases, which would be equally inconsistent. Interfaces are therefore the only, and also sufficient, mechanism for sharing behavior between multiple Enums.

In practice, interfaces are gladly combined with abstract methods that every implementing case is forced to fill in. An interface HasLabel with the method label(): string ensures that PHPStan or Psalm reports an error as soon as a new enum implements the interface but forgets the method. This combination of interface contract and enum-owned method is one of the strongest arguments for Enums over loose constants, because static analysis can actually enforce completeness here.


declare(strict_types=1);

interface HasLabel
{
    public function label(): string;
}

interface HasColor
{
    public function color(): string;
}

// An enum can implement interfaces, but never extend a class
enum OrderStatus: string implements HasLabel, HasColor
{
    case Pending = 'pending';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::Pending => 'Awaiting confirmation',
            self::Shipped => 'On its way',
            self::Cancelled => 'Cancelled by customer or merchant',
        };
    }

    public function color(): string
    {
        return match ($this) {
            self::Pending => '#f59e0b',
            self::Shipped => '#3b82f6',
            self::Cancelled => '#ef4444',
        };
    }
}

// Polymorphic function: works for any enum implementing HasLabel
function renderBadge(HasLabel&HasColor $status): string
{
    return sprintf('<span style="color:%s">%s</span>', $status->color(), $status->label());
}

echo renderBadge(OrderStatus::Shipped);

6. Static Methods and cases()

Besides instance methods, PHP Enums also allow static methods, just like classes. A common pattern is a static factory method that determines the matching enum case from an external, not directly compatible value, for instance deriving a domain status from an HTTP status code, or mapping several legacy strings from an old system onto a single unified case. This is especially useful when the external data source does not exactly match the ->value strings chosen for the Backed Enum itself, so a plain from() is not enough.

The built-in static method cases() returns an array of all defined cases of an enum in declaration order, both for Pure and for Backed Enums. This is the mechanism that lets Enums be fully iterated at runtime, for example to populate an HTML select element with all status values, to build a validation list without duplicating the values, or to make sure in a unit test that behavior is defined for every case. Without cases(), this completeness would have to be maintained manually, with the risk that a new case gets forgotten.

Combining cases() with array_map() or array_filter() produces compact, declarative queries over an enum's entire value set, for example "all cases that are not a terminal state" or "all labels for a dropdown". This combination is one of the reasons why Enums produce noticeably less boilerplate in practice than equivalent class-constant solutions, where such a list would have to be maintained separately and kept in sync with every change.


declare(strict_types=1);

enum OrderStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    /**
     * Custom static factory: maps a legacy status code to the enum case.
     */
    public static function fromLegacyCode(int $code): self
    {
        return match ($code) {
            0, 1 => self::Pending,
            2 => self::Processing,
            3 => self::Shipped,
            4 => self::Delivered,
            default => self::Cancelled,
        };
    }

    public function isFinal(): bool
    {
        return $this === self::Delivered || $this === self::Cancelled;
    }
}

// cases() lists every defined case at runtime, in declaration order
$options = array_map(
    fn (OrderStatus $status) => ['value' => $status->value, 'final' => $status->isFinal()],
    OrderStatus::cases()
);

$openStatuses = array_filter(OrderStatus::cases(), fn ($s) => !$s->isFinal());

7. from() and tryFrom(): Safe Conversion

Backed Enums come with two built-in static methods to convert a raw scalar value back into an enum case: from() and tryFrom(). Both expect the scalar backing value, for example a string from a form field or a database row, and return the matching case. The difference lies in the failure case: from() throws a ValueError if no case matches the value given, while tryFrom() silently returns null in that situation.

This distinction is not a matter of taste, it follows from where the data comes from. If the value comes from a source whose validity has already been guaranteed elsewhere, for example one's own database column with an enum constraint, from() is the right choice: an invalid value there would be a genuine, unexpected error condition that should fail loudly. If the value instead comes from an external, untrusted source, for example a query parameter, a webhook payload, or user input, tryFrom() is preferable, because an invalid value there is a normal, expected case that should be handled in a controlled way, for instance with a 422 response instead of an uncaught exception stack trace.

A common mistake is applying from() unchecked to user input and never catching the ValueError. That leads to 500 errors in production for a completely normal, expected situation: a client sends a value that no longer exists in this API version. The more robust approach combines tryFrom() with an explicit null check and a clear error message for the caller. That way, the type safety of PHP Enums is preserved without external, potentially malformed input crashing the system in an uncontrolled way.


declare(strict_types=1);

enum OrderStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
}

final class OrderStatusRequestHandler
{
    /**
     * Untrusted input (query parameter, webhook body): use tryFrom().
     */
    public function fromHttpRequest(string $rawStatus): OrderStatus
    {
        $status = OrderStatus::tryFrom($rawStatus);

        if ($status === null) {
            throw new InvalidArgumentException(
                sprintf('Unknown order status "%s" received from client', $rawStatus)
            );
        }

        return $status;
    }

    /**
     * Trusted internal source (own database column): from() is appropriate,
     * an invalid value here is a genuine, unexpected error condition.
     */
    public function fromDatabaseRow(string $dbValue): OrderStatus
    {
        return OrderStatus::from($dbValue); // throws ValueError on invalid data
    }
}

8. Enums in the match Expression

The match expression and PHP Enums complement each other particularly well, because unlike switch, match uses strict comparisons (===) and needs no break, but throws an UnhandledMatchError when a case is missing and there is no default. If a match over an enum is missing a case and has no default branch, the program aborts in a controlled way at runtime, instead of silently ignoring the case, as would happen with a switch without a matching case and without default.

Even more important for everyday practice is static analysis: tools like PHPStan and Psalm know the full set of values of an enum through cases() and can therefore detect at analysis time when a match over an enum is not exhaustive, meaning it fails to cover at least one case and has no default branch. That turns a potential runtime error into an immediately visible analysis error, long before the code is even deployed. This exact effect is one of the strongest practical advantages of Enums combined with match over the old combination of string constants and switch.

An additional, often overlooked benefit: when a new case is added to an existing enum, for example an extra order status, static analysis flags an error at every place in the code where an exhaustive match over that enum exists but does not handle the new case. This "the compiler helps with refactoring" property practically does not exist with string-based constants and switch statements, because nobody automatically knows in how many places in the code which values are being reacted to.

9. Backed or Pure: Decision Criteria

The decision between Pure and Backed almost always comes down to a single question in practice: does the value need to leave the boundaries of the current PHP process? If a state is stored in a database, exchanged over a REST or GraphQL API, written to a configuration file, or serialized into a queue message, it needs a stable, explicit scalar representation, in other words a Backed Enum. If a state instead stays entirely within a single request or a single class, without ever being persisted or transported across an interface, a Pure Enum is enough, and there is no obligation to invent a meaningful string or integer for every case.

A second criterion is API compatibility over time. The ->value of a Backed Enum becomes an implicit contract: changing it later, say from 'shipped' to 'in_transit', breaks every already-stored record and every external consumer expecting the old value. The case name itself, for example Shipped, can be renamed in the code at any time without affecting the persistence layer, as long as the ->value stays stable. This separation between the internal case name and the external backing value is one of the underrated advantages of PHP Enums over class constants, where name and value are often identical and thus equally fragile.

A third criterion concerns migrating existing code. Anyone currently working with string constants and switching to Enums should generally start with Backed Enums, because the existing string values typically already exist in databases, logs, and external systems. A Backed Enum with the same ->value strings as the old constants allows an incremental migration in which from() and tryFrom() serve as a bridge between old and new code, without requiring existing data to be migrated. Pure Enums, on the other hand, are better suited for entirely new, purely internal concepts that start out without legacy baggage from the beginning.

Scenario Class Constants PHP Enums Advantage
Type safety string $status OrderStatus $status Invalid values are impossible, no typos
IDE autocomplete Constants must be known upfront OrderStatus:: lists every case Faster, error-free development
match exhaustiveness switch checks nothing statically PHPStan detects missing cases Missing cases surface before deploy
Database persistence Raw string with no type guarantee Backed Enum with ->value Clearly typed, yet DB-compatible
Encapsulating behavior Separate helper class needed Methods directly on the enum Domain logic lives with the data type
Purely internal state (Pure) Arbitrary placeholder value needed Pure Enum without a backing value No invented string for a purely internal case

The table shows: Enums are at least on par in every scenario listed, usually clearly superior to class constants. The only extra effort lies in the initial modeling, that is deciding whether an enum should be Pure or Backed, and in migrating existing code. That effort typically pays for itself after the first few prevented typo bugs.

10. Summary

PHP Enums replace class constants and magic strings with a genuine, type-safe language mechanism. Pure Enums model purely internal states without a backing value, Backed Enums bring a stable ->value for persistence, serialization, and API exchange. Methods directly on the enum encapsulate domain logic in the right place, interfaces allow polymorphism across multiple enums, even though enums themselves cannot extend classes. cases() returns all variants at runtime, from() and tryFrom() convert trusted and untrusted input sources appropriately, and the match expression makes missing cases visible to static analysis instead of swallowing them at runtime.

Anyone still working with string constants for finite sets of values today should prioritize migrating to Enums, especially wherever statuses, roles, payment methods, or shipping methods are queried in many places throughout the code. The transition can happen incrementally, because Backed Enums can coexist with the same values as the old constants, while from() and tryFrom() serve as a bridge between old and new code. The gain in type safety, IDE support, and static analysis capability justifies the manageable migration effort in practically every production PHP 8.1-or-newer project.

PHP Enums: Backed vs. Pure Enums, the Key Takeaways

Pure vs. Backed

Pure Enums for purely internal states without a backing value, Backed Enums for anything that gets persisted or serialized.

Encapsulating behavior

Instance methods like label() or isFinal() bundle domain logic directly on the enum instead of spreading it across helper classes.

Safe conversion

from() for trusted internal sources with a ValueError, tryFrom() for external input with a controlled null.

match instead of switch

Exhaustive match over enum cases lets PHPStan catch missing cases before deploy.

11. FAQ: PHP Enums in Practice

1What are PHP Enums and since which version do they exist?
A distinct language mechanism since PHP 8.1 that lets you model a finite, named set of values in a type-safe way. They replace class constants and magic strings with genuine, type-system-checked values.
2What is the difference between Pure and Backed Enums?
Pure Enums have no scalar value, only a case identity. Backed Enums bind an int or string value to each case, accessible via the value property, which enables persistence and serialization.
3Can Enums have methods?
Yes. Enums can define instance methods and static methods, just like classes. A method can use match ($this) to deliver different behavior depending on the case, for example a label or a color.
4Can Enums implement interfaces?
Yes, using the same syntax as classes. However, an enum cannot extend a class and cannot inherit from another enum. Interfaces are the only way to share behavior between multiple enums.
5What does cases() do on Enums?
A built-in static method that returns an array of all defined cases of an enum in declaration order, useful for dropdowns, validation lists, and complete test coverage.
6What is the difference between from() and tryFrom()?
Both convert a backing value into the matching enum case. from() throws a ValueError on an invalid value, tryFrom() returns null instead. tryFrom() is suited for untrusted external input.
7Why is match with enums safer than switch with constants?
match compares strictly with === and throws an UnhandledMatchError without a matching case and without default. Static analysis tools like PHPStan additionally detect when a match over an enum does not cover all cases.
8When should I use Backed instead of Pure Enums?
Whenever the value crosses system boundaries: database persistence, JSON APIs, configuration files, or queue messages. If a state stays purely internal, a Pure Enum without a backing value is enough.
9Are enum cases in PHP immutable?
Yes. Every case is a singleton instance with no callable constructor and no way to change its state afterwards, comparable to the immutability of readonly properties, only anchored structurally in the language core.
10Can I replace existing string constants with Enums step by step?
Yes. A Backed Enum with the same value strings as the old constants allows an incremental migration. from() and tryFrom() serve as a bridge between existing data and new, type-safe code.

Mironsoft

PHP code reviews, modernization, and type-safe domain modeling

Magic strings and class constants in your code?

We review existing PHP codebases for fragile constant sets and replace them with PHP Enums that carry methods, interfaces, and safe conversion, so states, roles, and status values stay type-safe and maintainable.

Code Review

Analysis of existing constant sets and magic strings for migration potential toward Enums

Modernization

Step-by-step migration to Backed and Pure Enums with from()/tryFrom() as a compatibility bridge

Domain Modeling

Type-safe state models with enum methods, interfaces, and PHPStan enforcement at level 5+