Enums

Model closed sets with PHP enums, including pure and backed enums, boundary parsing, methods, serialization, and safe evolution.

level intermediate time 13 min at Standard depth
version PHP 8.3.33
what

An enum defines a finite, closed set of legal values as its own type. A pure enum distinguishes cases; a backed enum also gives each case a unique string or int value.

trap

name, value, and external data aren’t interchangeable, and match does not prove exhaustiveness at compile time. Default arms and silent fallbacks can hide new states or corrupt data.

fix

Call from() or tryFrom() explicitly at input boundaries, persist a stable backing value, and keep domain code typed to the specific enum.

What it is and why it exists

An enumeration is a finite set of legal values declared by the program. In PHP 8.1 and later, an enum declaration creates a real type. It is neither an ordinary class full of constants nor a naming convention for strings. Once a parameter is declared as OrderStatus, a caller must pass one of that enum’s cases, not a string that happens to have the same contents.

Every enum case is a singleton object of its enum type. Reading OrderStatus::Paid twice gives you the same case, so === compares it directly. A case can also be matched, passed to a typed function, and use methods declared by its enum.

Enums fit concepts whose members are owned by the code and mutually exclusive, such as order states, command kinds, or deployment environments. They stop invalid states at typed boundaries and show IDEs and static analyzers the full candidate set. A database row, JSON document, or form still supplies scalar data, so you gain that guarantee only after parsing the boundary.

An enum is usually a poor model when plugins must extend the set or several options may apply at once. A plugin registry is an open set; a permission combination behaves more like a set or bit flags. Forcing either concept into one enum tends to produce ever-growing cases and awkward combination states.

PHP has two enum forms. A pure enum declares case names only. A backed enum explicitly associates each case with a unique string or int. You need a backing value when the case must round-trip through a database, message, or API.

FormExample caseAvailable dataTypical use
Pure enumcase Morning;Read-only nameDistinguish categories inside PHP
Backed enumcase Paid = 'paid';Read-only name and valueRound-trip through scalar boundaries

An enum creates a nominal type boundary. Two enums may each contain a case backed by 'paid', but their cases still have different types and cannot be substituted. That distinction prevents an order status from reaching an invoice-status parameter, something plain strings cannot do.

How it works

Declarations and case objects

A pure enum uses enum Name { case Value; }. Its cases have no application-defined scalar value, but each one has a built-in, read-only name property containing its source identifier. name is useful for diagnostics and developer tools; it need not be your durable external protocol.

A backed enum places : string or : int after its name and assigns every case a value. One enum cannot mix the two backing types or omit a value from one case. Backing values must be unique, and PHP does not generate sequential integers for you.

A backed case also has a built-in, read-only value property. It contains the scalar written in the declaration and can be stored in a database or sent to an API that defines that representation. It is not an ordinary property: you cannot assign to it or modify it through a reference.

Cases are objects, but an enum cannot give each object arbitrary state. You cannot construct a case with new, clone one, or declare instance or static properties on an enum. Enums have no inheritance hierarchy: they cannot extend a class, and neither a class nor another enum can extend them.

UnitEnum and BackedEnum

Every enum automatically implements the internal UnitEnum interface. Its static cases() method returns a packed array of all cases in declaration order. An enum cannot redefine cases(), and an ordinary class cannot implement UnitEnum manually.

A backed enum also implements BackedEnum automatically. That interface supplies from() and tryFrom(). Both look up a backing value, not the case’s name. The result is the existing singleton case, not a newly constructed object.

from() throws ValueError when no case has the value. That fail-fast behavior fits a trusted database value where an unknown value means corrupt data or a deployment mismatch. tryFrom() returns null instead, which fits an external-input boundary that needs to produce a validation error.

Under declare(strict_types=1), conversion methods on a string-backed enum require a string, while an int-backed enum requires an integer. Do not rely on a weak call to happen to coerce input into the backing type. Validate the raw representation first, then call the conversion method with the exact type so the boundary policy stays visible.

Methods, interfaces, and traits

Pure and backed enums may declare instance methods, static methods, and constants. In an instance method, $this is the current case, so match ($this) can provide behavior for every case. An enum may also implement one or more interfaces, just like an ordinary object.

An interface lets calling code depend on behavior rather than one concrete enum. If OrderStatus and a regular class both implement Labelled, a function accepting Labelled can handle both. Each enum’s case set remains closed; the interface does not merge cases from separate enums into one value type.

Enums may use traits, but those traits cannot contain properties. A trait that contains only methods, static methods, or constants can be reused; using one with a property in an enum causes a fatal error. Check that the behavior really belongs to every consumer before sharing it. A generic values() helper, for example, works only for backed enums.

Enums cannot declare constructors or destructors, and most magic methods are forbidden. Those limits keep cases free of per-object mutable state. A domain object that needs runtime data should hold an enum property instead of forcing that data into the enum itself.

match and complete branches

match uses identity comparison, which makes enum cases natural conditions. Without a default arm, PHP throws UnhandledMatchError if execution reaches an uncovered case. This is runtime protection, not an exhaustiveness proof from the PHP compiler.

Adding a case does not stop the declaration from loading. The omission appears only when that case reaches the match, or when a static analyzer finds it. For domain decisions that must change with the case set, omit default and keep a coverage test for every case.

Some conversions genuinely have a reasonable fallback, such as mapping every unrecognized display color to gray. A default arm can still swallow a case added later. Use it only when a new case may safely inherit the fallback behavior without creating a business error.

Examples

These four examples cover pure enums, scalar boundary conversion, behavior-bearing state enums, and generic enum code. Every output was produced by the local PHP 8.3.33 CLI.

Closing over a set of delivery windows

DeliveryWindow is a pure enum. The function signature excludes arbitrary strings, while cases() supplies every option from the declaration.

delivery_window.php
<?php

declare(strict_types=1);

enum DeliveryWindow
{
    case Morning;
    case Afternoon;
    case Evening;
}

function cutoffHour(DeliveryWindow $window): int
{
    return match ($window) {
        DeliveryWindow::Morning => 11,
        DeliveryWindow::Afternoon => 16,
        DeliveryWindow::Evening => 20,
    };
}

foreach (DeliveryWindow::cases() as $window) {
    printf("%s closes at %d\n", $window->name, cutoffHour($window));
}
Morning closes at 11
Afternoon closes at 16
Evening closes at 20

cases() preserves declaration order, so the output follows the source. cutoffHour() has no default; if a future window is added, a test that iterates over every case will execute the missing branch and fail.

The example uses name only to print an identifier. If a customer-facing interface needs translated text, use a separate translation key or presentation-layer mapping instead of exposing a source identifier as copy.

Recovering a case from external data

InvoiceState is a string-backed enum. The example uses tryFrom() for possibly invalid data, demonstrates how from() fails for data expected to be valid, and observes the default JSON representation of a backed enum.

invoice_state.php
<?php

declare(strict_types=1);

enum InvoiceState: string
{
    case Draft = 'draft';
    case Sent = 'sent';
    case Paid = 'paid';
}

foreach (['draft', 'paid', 'refunded', 'PAID'] as $raw) {
    $state = InvoiceState::tryFrom($raw);
    printf("%s => %s\n", $raw, $state?->name ?? 'invalid');
}

try {
    InvoiceState::from('refunded');
} catch (ValueError $error) {
    echo $error::class, PHP_EOL;
}

echo json_encode(InvoiceState::Paid, JSON_THROW_ON_ERROR), PHP_EOL;
draft => Draft
paid => Paid
refunded => invalid
PAID => invalid
ValueError
"paid"

Lookup is case-sensitive against value, so 'PAID' does not match 'paid'. Case folding, trimming, and aliases belong to the input protocol. If you need them, normalize explicitly before calling tryFrom() instead of hiding fuzzy conversion inside the enum.

The final line shows that a backed enum encodes to its scalar value by default. An enum property inside a JSON object follows the same rule. JSON_THROW_ON_ERROR turns encoding failure into an exception instead of letting a caller overlook false.

Keeping state transitions beside the enum

Allowed next states depend closely on the current case, so an instance method can express them. The interface requires every state to provide label behavior.

order_transitions.php
<?php

declare(strict_types=1);

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

enum OrderStatus: string implements Labelled
{
    case Created = 'created';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return ucfirst($this->value);
    }

    public function canMoveTo(self $next): bool
    {
        return match ($this) {
            self::Created => in_array($next, [self::Paid, self::Cancelled], true),
            self::Paid => in_array($next, [self::Shipped, self::Cancelled], true),
            self::Shipped, self::Cancelled => false,
        };
    }
}

$current = OrderStatus::Created;
foreach ([OrderStatus::Paid, OrderStatus::Shipped, OrderStatus::Created] as $next) {
    $allowed = $current->canMoveTo($next);
    printf("%s -> %s: %s\n", $current->label(), $next->label(), $allowed ? 'yes' : 'no');
    if ($allowed) {
        $current = $next;
    }
}
Created -> Paid: yes
Paid -> Shipped: yes
Shipped -> Created: no

The method decides whether two cases form an allowed transition; it does not mutate shared state. The caller updates its own $current after receiving true, so ownership remains clear. A production system would usually check the current database version inside a transaction as well. An enum method cannot prevent concurrent writes by itself.

The third argument to in_array() is true. Cases are already objects, but strict comparison states the intent and avoids introducing loose comparison if the list later changes to scalars.

Narrowing enum types in generic code

A function that accepts UnitEnum may receive either a pure or a backed enum. It must first narrow to BackedEnum before reading value.

enum_metadata.php
<?php

declare(strict_types=1);

enum AccessMode
{
    case Read;
    case Write;
}

enum ResponseKind: int
{
    case Success = 200;
    case Missing = 404;
}

function describeCase(UnitEnum $case): string
{
    if ($case instanceof BackedEnum) {
        return "{$case->name}={$case->value}";
    }
    return $case->name;
}

foreach ([AccessMode::Read, ResponseKind::Missing] as $case) {
    printf("%s: %s\n", $case::class, describeCase($case));
}

var_export(enum_exists(ResponseKind::class));
echo PHP_EOL;
AccessMode: Read
ResponseKind: Missing=404
true

$case::class returns the concrete enum name, while describeCase() depends only on internal interfaces. enum_exists() checks whether a name identifies a defined or autoloadable enum; it does not replace a business allowlist.

This kind of generic function fits diagnostics, form builders, and framework adapters. Domain logic should usually accept AccessMode or ResponseKind; otherwise its signature admits unrelated enums and postpones mistakes until the function body.

Pitfalls

Treating a scalar as an enum

The specific type boundary is the source of an enum’s value. If domain functions continue to accept string, comments and manual checks still carry the legal-value contract, and the type system cannot stop typos or values from the wrong domain.

Fix: validate raw type and syntax at the boundary, then call tryFrom(); use from() to fail immediately when an internal invariant is broken. After conversion, pass only OrderStatus, and preserve the original field and value when reporting a boundary error.

Silently turning invalid input into a default case

That fallback looks resilient but manufactures a false business fact. Between services on different versions, a case sent by a newer producer may become the default state in an older consumer, causing a wrong notification or a state regression.

Fix: define separate policies for a missing field and an invalid value. An optional field can remain null before conversion; an invalid field should produce a clear validation error. Fall back only when the protocol explicitly gives unknown values a default meaning, and test that behavior.

Confusing name and value

Persisting name accidentally makes a PHP identifier part of the database contract. Using value for development diagnostics may hide the source name. A pure enum has no value at all, so a helper accepting UnitEnum cannot read it unconditionally.

Fix: decide whether each boundary needs a source identifier, external scalar, or user-facing label. Persist the value of a backed enum, use name when it helps diagnostics, and keep display text in the translation layer. Generic code should narrow with instanceof BackedEnum first.

Hiding new cases behind default

PHP does not check an enum match for exhaustiveness at compile time. Without default, an omission throws UnhandledMatchError only when the case reaches that code. With a default arm, even that signal disappears.

Fix: omit default when the decision must be made case by case, and make a parameterized test iterate over cases(). Static analysis can report an omission sooner, but tests still need to verify the actual result for every case.

Using a closed set as an extension mechanism

Likewise, one enum variable holds exactly one case. Turning every permission combination into cases such as ReadWriteExecute makes the set grow quickly and gives callers no clean way to ask about one capability.

Fix: use an interface and registry for an open set; use a collection, value object, or carefully designed bit flags for combinable capabilities. Choose an enum only when this codebase controls the members and must reject unknown ones.

Changing a persisted representation casually

JSON stores the value of a backed enum, while PHP’s serialize() records the enum type and case name. The two formats depend on different identifiers. A rename that looks like source cleanup may require protocol versioning, a data migration, or compatibility reads.

Fix: treat a published backing value as an external contract and inventory every written format. Deploy readers that accept old and new values, migrate stored data, and only then stop writing the old value. Do not build a durable cross-service protocol on PHP serialization.

Deep Type relationships and runtime limits

Type relationships and runtime limits

Enum declarations share a namespace with classes, interfaces, and traits, and they use the same autoloading mechanism. A case is an object, an instance of its concrete enum type, and an instance of UnitEnum; a backed case is also an instance of BackedEnum. Accept the concrete enum when you want the narrowest contract, or an interface when you want behavior shared across types.

DeclarationAccepted valuesMembers safe to use
OrderStatusCases of OrderStatus onlyIts methods, name, and value when backed
UnitEnumAny pure or backed enum casename; cases() on a concrete class
BackedEnumAny backed enum casename and value
LabelledEnum cases or regular objects implementing itThe declared label() method

UnitEnum::cases() is static, while a parameter usually contains one case instance. Code that lists an arbitrary enum class can accept a PHPDoc class-string<UnitEnum>, validate it with enum_exists(), and then call $enumClass::cases(). A native string declaration alone cannot prove that the class string names an enum.

ReflectionEnum can inspect whether an enum is backed, its backing type, and its cases in framework or tooling code. Most domain logic does not need reflection; a concrete enum type or small interface says more directly what it needs. A generic abstraction pays off only when the code truly handles unknown enum types.

Singleton identity means the same case compares reliably with ===. It does not give cases a natural ordering, nor does the numeric order of backing values define domain order. Priority and workflow order need explicit methods or mappings instead of hints from declaration position or scalar size.

Enums have no properties, so they cannot turn each case into a small object with mutable fields. A method may calculate from $this or reach an external service, but the latter hides dependencies and makes tests harder. Keep pure domain mappings on the enum and put I/O and transactions in services.

Serialization and boundary evolution

A backed enum’s default JSON representation is just its backing value: a string case becomes a JSON string, and an integer case becomes a JSON number. A pure enum has no default JSON representation and throws JsonException with JSON_THROW_ON_ERROR. Either kind may implement JsonSerializable, but doing so establishes one global format for every call site.

One global format may not fit every API. One response may need only 'paid', a diagnostic endpoint may need {name, value}, and another protocol may require a version field. Projecting at the resource or DTO layer is often easier to evolve than implementing JsonSerializable globally on the enum.

PHP native serialization uses a dedicated representation containing the enum type and case name. Unserialization restores the existing singleton case; if the type or case cannot be found, it issues a warning and returns false. This format can suit short-lived internal data within one PHP deployment, but not a language-neutral durable protocol.

A database column stores only the backing value and does not automatically acquire the closed-set guarantee from PHP. Rejecting invalid values at rest requires a database constraint, controlled write paths, or both. When application conversion fails, include the record identifier and raw value so operators can distinguish corrupt data from deployment ordering.

Adding a case usually keeps old data readable but can still break old consumers. An older PHP service sees the new value as unknown through tryFrom(), and a match without the new arm fails at runtime. During a rolling deployment, give consumers an explicit unknown-value policy before producers start emitting the new value.

Deleting or renaming a case is riskier. A careful migration stops producing the old value, teaches readers both representations for a transition, migrates stored data, drains old queue and cache entries, and finally removes compatibility code. An enum makes the legal set visible; it does not perform a distributed protocol migration for you.

Further reading

checkpoint

5 questions · 2 predict-the-output · 1 spot-the-bug

Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?