# Enums

Source: https://codewiki.com/php/enums/

> - **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.

| Form | Example case | Available data | Typical use |
| --- | --- | --- | --- |
| Pure enum | `case Morning;` | Read-only `name` | Distinguish categories inside PHP |
| Backed enum | `case Paid = 'paid';` | Read-only `name` and `value` | Round-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.

<!-- quick -->

```php
// file: 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));
}
```

```text
Morning closes at 11
Afternoon closes at 16
Evening closes at 20
```

<!-- /quick -->

`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.

```php
// file: 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;
```

```text
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.

```php
// file: 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;
    }
}
```

```text
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`.

```php
// file: 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;
```

```text
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

> **Pitfall:** A database or HTTP request containing `'paid'` has not produced `OrderStatus::Paid`. Passing that string directly to an `OrderStatus` parameter throws `TypeError`.

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

> **Pitfall:** `OrderStatus::tryFrom($raw) ?? OrderStatus::Created` interprets missing input, a typo, and an unknown new value as "created."

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`

> **Pitfall:** For `case Paid = 'paid'`, `name` is `'Paid'`, while `value` is `'paid'`. `from()` and `tryFrom()` look up only the latter.

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`

> **Pitfall:** Adding `default` to every `match` hides missing branches. After a case is added, old logic keeps running but may choose a completely wrong business branch.

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

> **Pitfall:** Enums cannot be extended or receive cases at runtime. Modeling third-party payment providers as one enum forces the core package to release a version for every plugin.

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

> **Pitfall:** Changing a backing value, case name, or enum class name can break existing database rows, messages, caches, and PHP-serialized data.

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

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.

| Declaration | Accepted values | Members safe to use |
| --- | --- | --- |
| `OrderStatus` | Cases of `OrderStatus` only | Its methods, `name`, and `value` when backed |
| `UnitEnum` | Any pure or backed enum case | `name`; `cases()` on a concrete class |
| `BackedEnum` | Any backed enum case | `name` and `value` |
| `Labelled` | Enum cases or regular objects implementing it | The 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`, 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.

<!-- /deep -->

[Checkpoint: php/enums](https://codewiki.com/php/enums/#checkpoint)

## Further reading

- [PHP manual: basic enumerations](https://www.php.net/manual/en/language.enumerations.basics.php)
- [PHP manual: backed enumerations](https://www.php.net/manual/en/language.enumerations.backed.php)
- [PHP manual: listing enum values](https://www.php.net/manual/en/language.enumerations.listing.php)
- [PHP manual: enumeration methods](https://www.php.net/manual/en/language.enumerations.methods.php)
- [PHP manual: differences from objects](https://www.php.net/manual/en/language.enumerations.object-differences.php)
- [PHP manual: enum serialization](https://www.php.net/manual/en/language.enumerations.serialization.php)
