Pure and Backed Enums: Cases, Methods, Interfaces, and Serialization

Understand PHP 8.1's native enum type in depth — the difference between pure and backed enums, defining methods and constants on them, implementing interfaces, using from()/tryFrom() for safe conversion, and how enums behave under serialization, comparison, and match expressions — so you can replace fragile class-constant and string-constant patterns with a real, type-safe enumeration type.

For its entire history before 8.1, PHP had no true enumeration type. Developers simulated enums with class constants (class Status { const ACTIVE = 'active'; const INACTIVE = 'inactive'; }), which gave no real type safety — any string could still be passed where a Status was expected, and there was no way to iterate all possible values or attach behavior to a specific case. PHP 8.1 finally added enums as a genuine first-class language construct: a closed, type-safe set of named cases that can carry methods, implement interfaces, and — for the 'backed' variant — map directly to and from a scalar value like a string or int, which is exactly what's needed for database columns and API payloads.

Pure Enums: A Closed Set of Named Cases

A pure enum is declared with the enum keyword and lists a fixed set of case values. enum Status { case Active; case Inactive; case Pending; } defines a brand-new type, Status, whose only possible values are Status::Active, Status::Inactive, and Status::Pending. Each case is a singleton instance of the enum — comparing two references to Status::Active with === always returns true, because there is exactly one instance of each case in the entire program, not a fresh object each time.

Because an enum is a real type, you can type-hint a parameter or return value with it: function setStatus(Status $status): void { ... }. This is the core value proposition over string constants — the compiler/engine now rejects any call that doesn't pass an actual Status case, closing off an entire category of bugs where an invalid or misspelled string slips through silently.

Enum cases can be listed with a special built-in static method: Status::cases() returns an array of every case, in declaration order — [Status::Active, Status::Inactive, Status::Pending]. This is extremely useful for building select-option lists, validating input against the full set of allowed values, or iterating in tests.

• enum Status { case Active; case Inactive; } — declares a pure (unbacked) enum. • Each case is a singleton — Status::Active === Status::Active is always true (identity, not value, comparison). • Status::cases() — built-in static method returning all cases as an array, in declaration order. • A pure enum has no underlying scalar value by default — it exists purely as a type-safe set of identities.

Backed Enums: Mapping Cases to Scalar Values

A backed enum attaches a literal scalar value (either all string or all int, never mixed) to every case, declared with : string or : int after the enum name: enum Status: string { case Active = 'active'; case Inactive = 'inactive'; case Pending = 'pending'; }. Every case in a backed enum MUST have a value — you cannot mix backed and unbacked cases in the same enum. The scalar value is accessed via the ->value property: Status::Active->value is 'active'.

Backed enums exist specifically to solve the boundary problem: databases store scalars, JSON payloads carry scalars, HTML form values are strings — none of them can carry a PHP object identity directly. A backed enum gives you a real, type-safe enum inside your PHP code while still having a clean, stable scalar representation at the boundary. This is why almost every enum used for things like order status, user role, or HTTP method in real applications is backed rather than pure.

Choosing int vs string backing is a real design decision: string-backed enums ('active', 'pending') are self-documenting in a database column or JSON payload and safe to reorder in the source file without breaking stored data, whereas int-backed enums are more compact but meaningless without the enum definition, and — like old-style int constants — are more fragile if someone renumbers cases. The general recommendation for anything persisted (database, API contracts) is to prefer string backing unless there's a specific storage-size reason not to.

• enum Status: string { case Active = 'active'; ... } — every case must declare a value; all values share one scalar type (string or int). • Status::Active->value — accesses the underlying scalar ('active'). • A backed enum's declared type shows in its signature (Status: string) — you cannot mix string and int values in one enum. • Prefer string-backed enums for anything persisted or serialized — they're self-documenting and safe to reorder.

from() and tryFrom(): Converting Scalars Back to Cases

Backed enums automatically get two static methods for converting a raw scalar back into a case. Status::from('active') returns Status::Active if 'active' matches a case's value, or throws a ValueError if it doesn't match anything — use this when an invalid value genuinely represents a bug or corrupted data you want to fail loudly on. Status::tryFrom('unknown') does the same lookup but returns null instead of throwing when there's no match, which is the right choice when the input is untrusted external data (user input, an external API response) that you want to validate gracefully.

The typical pattern for handling untrusted input is: $status = Status::tryFrom($request->get('status')) ?? Status::Pending; — attempt the conversion, and fall back to a sensible default (or return a validation error) if it fails, combining tryFrom() with ?? from the null-coalescing lesson.

from() and tryFrom() only exist on backed enums — pure enums have no scalar representation to convert from, so calling Status::from(...) on a pure (unbacked) enum is a compile-time error, not a runtime one; PHP catches this mismatch statically.

• Status::from('active') — returns the matching case, or throws ValueError if no case has that value. • Status::tryFrom('bogus') — returns the matching case, or null if there's no match (no exception). • Idiomatic pattern: Status::tryFrom($input) ?? Status::Default; for validating untrusted external input. • from()/tryFrom() exist only on backed enums — pure enums don't have them.

Methods, Constants, Interfaces, and match() on Enums

Enums can declare methods just like classes, which is where real behavior lives instead of scattering it across if/switch statements elsewhere in the codebase: public function label(): string { return match($this) { self::Active => 'Active', self::Inactive => 'Inactive', self::Pending => 'Pending awaiting review', }; }. Because match() is exhaustive-friendly (it throws UnhandledMatchError on no match, unlike switch's silent fallthrough), adding a new case and forgetting to update a method's match() is caught at runtime the moment that case is used, rather than silently returning nothing.

Enums can also declare their own constants (const DEFAULT = self::Pending;) and, critically, can implement interfaces: enum Status: string implements HasColor { case Active = 'active'; public function color(): string { return match($this) { self::Active => 'green', default => 'gray' }; } }. This lets you type-hint against the interface (HasColor $thing) and pass an enum case anywhere that interface is expected, which is how enums integrate cleanly with existing polymorphic code. Enums cannot, however, extend a class or be extended — they can only implement interfaces, since the whole point is a closed, fixed set of instances.

Static methods are also allowed on enums, commonly used as alternate constructors or grouped lookups: public static function default(): self { return self::Pending; }. Enum cases can be compared with ===, used directly as array keys is NOT allowed (enum cases can't be array keys — only int/string can — but they work perfectly fine as match() subjects, in arrays as VALUES, and inside \SplObjectStorage or as keys of a WeakMap).

• Enums can declare methods, static methods, and their own constants, just like classes. • Enums can implement one or more interfaces (enum Status implements HasColor), but cannot extend classes or be extended. • match($this) inside an enum method is exhaustive-checked at runtime — an unhandled case throws UnhandledMatchError rather than failing silently. • Enum cases CANNOT be used directly as array keys (only int/string can) — use $case->value for a backed enum, or $case->name for any enum, as a key instead.

Reflection, Serialization, and Comparison Semantics

Every enum case has a ->name property (a string, always present on both pure and backed enums) giving its declared case name — Status::Active->name is 'Active'. This is distinct from ->value, which only exists on backed enums and gives the underlying scalar. ->name is useful for debugging, logging, and any place you want the PHP-side identifier rather than the storage-side value.

json_encode() on a backed enum serializes to its ->value automatically (a string-backed Status::Active becomes "active" in the JSON output) because backed enums implement the built-in JsonSerializable-compatible behavior out of the box. A pure enum, having no scalar value, CANNOT be json_encode()'d directly — attempting to do so throws an Error, so pure enums used in API responses need an explicit method (commonly implementing JsonSerializable yourself, returning $this->name or a custom label) to control their JSON shape.

PHP's native serialize()/unserialize() and session storage handle enum cases correctly by reference to the singleton case (as of PHP 8.1), and enums also work correctly as readonly property values, in match() subjects, and with instanceof checks — $status instanceof Status works exactly like it does for a class. Reflection has dedicated classes for enums (ReflectionEnum, ReflectionEnumUnitCase, ReflectionEnumBackedCase) which frameworks use to enumerate an enum's cases, read attributes attached to individual cases, and inspect whether an enum is backed — this is how libraries like ORMs and validators automatically discover and map enum types without you writing that logic yourself.

• ->name — always present, gives the case's identifier as a string ('Active'). • ->value — only on backed enums, gives the underlying scalar ('active'). • json_encode() on a backed enum case automatically produces its ->value; a pure enum cannot be json_encode()'d without custom logic. • ReflectionEnum / ReflectionEnumBackedCase let frameworks introspect enum cases and any attributes attached to them at runtime.

Quick Reference: Declaring and Using Enums

Every case in a backed enum needs a value; a pure enum's cases have none. from() throws ValueError on no match; tryFrom() returns null — pick based on whether the input is trusted. Enums implement interfaces but never extend/are extended by classes.

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

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

    public function label(): string
    {
        return match($this) {
            self::Pending   => 'Awaiting shipment',
            self::Shipped   => 'On its way',
            self::Delivered => 'Delivered',
            self::Cancelled => 'Cancelled',
        };
    }

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

    public static function default(): self
    {
        return self::Pending;
    }
}

$status = OrderStatus::from('shipped');       // OrderStatus::Shipped
$maybe  = OrderStatus::tryFrom('bogus');      // null
$all    = OrderStatus::cases();               // [Pending, Shipped, Delivered, Cancelled]
echo $status->value;                          // 'shipped'
echo $status->label();                        // 'On its way'
echo json_encode($status);                    // "shipped"

Code Examples

Direction has no natural scalar representation, so it's left unbacked (pure). The opposite() method uses an exhaustive match($this) — if a new case (e.g. Direction::Up) were added later without updating opposite(), calling it on that case throws UnhandledMatchError immediately rather than silently returning null.

public function opposite(): self { return match($this) {... }; } resolveRole() combines tryFrom() with the ?? operator from the previous lesson to turn arbitrary, possibly-invalid external input into a guaranteed-valid Role, defaulting to the least-privileged Viewer role rather than throwing on bad input. The trusted-data path uses from() instead, so that a corrupted database value fails loudly with a ValueError rather than silently becoming null. Expected result: editor / viewer / viewer return Role::tryFrom($input ?? '') ?? Role::Viewer;

renderBadge() is type-hinted against the HasColor interface, not the concrete Priority enum, so it works for any enum (or class) implementing HasColor. Priority is int-backed since its numeric ordering (1 < 2 < 3) is meaningful for sorting by urgency, unlike the string-backed examples above where order doesn't matter.

function renderBadge(HasColor $item): string json_encode() recurses into the array and, on hitting the backed enum case Status::Active, automatically emits its ->value ('active') rather than throwing — this is built-in behavior for backed enums, not something you have to implement. ReflectionEnum::isBacked() and getCases() are what frameworks use under the hood to auto-generate things like OpenAPI schemas or database column definitions from your enum types. Expected result: {"id":42,"status":"active"} backed Active Archived echo json_encode($payload); // status becomes "active"

enum Direction
{
    case North;
    case South;
    case East;
    case West;

    public function opposite(): self
    {
        return match($this) {
            self::North => self::South,
            self::South => self::North,
            self::East  => self::West,
            self::West  => self::East,
        };
    }
}

$d = Direction::North;
echo $d->opposite()->name; // "South"
var_dump(Direction::North === Direction::North); // true — singleton identity
enum Role: string
{
    case Admin    = 'admin';
    case Editor   = 'editor';
    case Viewer   = 'viewer';
}

function resolveRole(?string $input): Role
{
    // Untrusted input (e.g. from $_GET) — use tryFrom + a safe default
    return Role::tryFrom($input ?? '') ?? Role::Viewer;
}

echo resolveRole('editor')->value;  // "editor"
echo resolveRole('nonsense')->value; // "viewer" (fell back safely)
echo resolveRole(null)->value;       // "viewer"

// Trusted internal value — from() throws loudly if it's ever wrong, which is desired here
$storedRole = Role::from('admin'); // Role::Admin
interface HasColor { public function color(): string; }

enum Priority: int implements HasColor
{
    case Low = 1;
    case Medium = 2;
    case High = 3;

    public function color(): string
    {
        return match($this) {
            self::Low => 'gray',
            self::Medium => 'orange',
            self::High => 'red',
        };
    }
}

function renderBadge(HasColor $item): string
{
    return "<span style=\"color: {$item->color()}\">badge</span>";
}

echo renderBadge(Priority::High); // <span style="color: red">badge</span>
enum Status: string
{
    case Active = 'active';
    case Archived = 'archived';
}

$payload = ['id' => 42, 'status' => Status::Active];
echo json_encode($payload);
// {"id":42,"status":"active"}  -- backed enum auto-serializes to ->value

$reflection = new ReflectionEnum(Status::class);
echo $reflection->isBacked() ? 'backed' : 'pure'; // "backed"
foreach ($reflection->getCases() as $case) {
    echo $case->getName() . "\n"; // "Active" then "Archived"
}

Pure vs Backed Enum

A pure enum only has ->name and no scalar mapping; a backed enum adds ->value plus from()/tryFrom() and automatic JSON serialization to that value.

PURE ENUM                          BACKED ENUM (: string / : int)
  enum Direction {                   enum Status: string {
    case North;                        case Active = 'active';
    case South;                        case Inactive = 'inactive';
  }                                  }

  Direction::North                   Status::Active
    ->name  = "North"                  ->name  = "Active"
    ->value  <-- DOES NOT EXIST         ->value = "active"
                                        ::from('active')    -> Status::Active
  json_encode(Direction::North)         ::tryFrom('bogus')  -> null
    -> throws Error                   json_encode(Status::Active)
                                         -> "active"

Important Rules

Use a backed enum (string preferred) for any value that crosses a boundary — database column, JSON API, HTML form — and a pure enum only for values that never leave PHP.

Use tryFrom() + ?? for untrusted/external input; use from() for internal/trusted data where an unexpected value should throw loudly.

Put case-specific behavior in an enum method with an exhaustive match($this), not in an external switch/if scattered across the codebase.

Implement interfaces on enums to let them participate in existing polymorphic type hints instead of special-casing enum types everywhere.

Remember enum cases cannot be array keys directly — key by ->value (backed) or ->name (any enum) instead.

Where this shows up in production PHP

Backed enums are now the standard representation for order/payment status, user roles and permissions, HTTP method and status-code wrappers, and any 'select one of N fixed options' database column across modern PHP frameworks (Laravel and Symfony both added first-class enum casting/binding support once PHP 8.1 shipped). ORMs use ReflectionEnum to automatically map a backed enum's value to and from a database column, and validation libraries use Status::cases() to auto-generate the list of allowed values for a field, replacing what used to be manually-synced arrays of string constants that regularly drifted out of sync with the actual valid values.

enum Status: string { case Active = 'active'; case Inactive; } // missing value on one case enum Status: string { case Active = 'active'; case Inactive = 'inactive'; }

A backed enum requires EVERY case to declare a value of the declared backing type — mixing a valued case with a valueless one in a backed enum is a fatal compile error, not a warning.

$status = Status::from($_GET['status']); // untrusted input, throws on bad data

$status = Status::tryFrom($_GET['status'] ?? '') ?? Status::Pending;

from() throws ValueError on any non-matching value, which is appropriate for trusted internal data but wrong for raw request input — a malformed or malicious query string would crash the request instead of being handled gracefully with tryFrom() and a safe default.

$cache = [Status::Active => 'x']; // using an enum case directly as an array key

$cache = [Status::Active->value => 'x']; // or ->name for a pure enum

PHP array keys must be int or string; an enum case is an object instance and cannot be used as a key directly — this throws a TypeError at runtime. Use the backed ->value or the universal ->name instead.

What is the key structural difference between a pure enum and a backed enum in PHP 8.1? A. Pure enums can have methods, backed enums cannot B. Backed enums attach a scalar value (string or int) to each case and gain from()/tryFrom()/->value; pure enums have neither C. Pure enums are deprecated in favor of backed enums D. Backed enums can only be used with integers, never strings

B. Backed enums attach a scalar value (string or int) to each case and gain from()/tryFrom()/->value; pure enums have neither — The 'backing' is exactly the scalar value attached to each case (declared with : string or : int), which unlocks ->value and the from()/tryFrom() conversion methods. Pure enums have no scalar representation and thus none of that — but both kinds can equally have methods, constants, and interfaces.

What happens when you call Status::from('bogus') on a backed enum where no case has the value 'bogus'? A. Returns null B. Returns false C. Throws a ValueError D. Returns a new anonymous case with that value

C. Throws a ValueError — from() throws a ValueError when no case matches, which is why it's meant for trusted data where a mismatch is truly exceptional. tryFrom() is the graceful counterpart that returns null instead.

Which of these can an enum in PHP do? A. Extend another class B. Be extended by a subclass C. Implement an interface D. Have a mutable, non-singleton instance created with new Status()

C. Implement an interface — Enums can implement interfaces (letting them satisfy polymorphic type hints), but they can never extend a class, be extended, or be instantiated with new — every case is a fixed, pre-existing singleton.

What does json_encode() produce for a pure (unbacked) enum case passed directly, e.g. json_encode(Direction::North)? A. The string "North" (its ->name) B. An empty object {} C. It throws an Error, because a pure enum has no defined scalar representation D. The integer index of the case (0)

C. It throws an Error, because a pure enum has no defined scalar representation — Only backed enums have a defined scalar (->value) to serialize to, and json_encode() uses exactly that. A pure enum has nothing to fall back to, so encoding it directly throws — you must implement JsonSerializable yourself if you need a pure enum in a JSON payload.

Define a backed string enum PaymentMethod with cases Card, PayPal, BankTransfer (values 'card', 'paypal', 'bank_transfer'). Add a method fee(float $amount): float returning 2.9% of the amount for Card, a flat $0.50 for PayPal, and 0 for BankTransfer, using match($this). Then write a function parsePaymentMethod(?string $input): PaymentMethod that safely converts untrusted input, defaulting to Card if invalid or missing.

enum PaymentMethod: string { case Card = 'card'; case PayPal = 'paypal'; case BankTransfer = 'bank_transfer'; public function fee(float $amount): float { return match($this) { self::Card => $amount * 0.029, self::PayPal => 0.50, self::BankTransfer => 0.0, }; } } function parsePaymentMethod(?string $input): PaymentMethod { return PaymentMethod::tryFrom($input ?? '') ?? PaymentMethod::Card; }

Use enum PaymentMethod: string { ... }, match($this) inside fee(), and PaymentMethod::tryFrom($input ?? '') ?? PaymentMethod::Card.