From Scattered Status Strings to a Closed Set of Cases
The order-status code this topic replaces is a rumor mill:
const STATUS_PENDING = 'pending';
const STATUS_PAID = 'paid';
const STATUS_SHIPPED = 'shipped';
$order['status'] = $request->input('status'); // 'pended'? 'PAID '? anything goes
if ($order['status'] == 'paid') { /* ... */ } // 'PaiD' fails silently in the wrong branch
Constants document the allowed values but enforce nothing — any string can enter the system, typos hide in == comparisons, and IDE navigation cannot help with a bare string. An enum makes the set closed:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
}
$order['status'] = OrderStatus::Paid;
OrderStatus::from('paid') === OrderStatus::Paid; // true — cases are singletons
Two identity facts make the whole thing behave: each case is a singleton object — OrderStatus::Paid === OrderStatus::Paid is always true — and the enum is a real type, so function ship(OrderStatus $s) rejects a raw string with TypeError: Argument #1 must be of type OrderStatus, string given. Making invalid states unrepresentable is no longer a code-review request; it is the signature.
The : string after the name declares the enum backed — every case carries a unique string or int scalar, the value you store in the database column and ship over the API. The alternative is a pure enum with no backing value (enum Direction { case Up; case Down; }), which suits flags and modes that never leave PHP. Backing rules are strict: one backing type per enum (no int|string mixing), every case declares a unique literal value, and no auto-numbering — the explicitness is deliberate, because those literals are the wire format.
What you cannot do defines the type as much as what you can: new OrderStatus('paid') throws Error: Cannot instantiate enum OrderStatus, cases cannot be cloned, and enums cannot be extended — each enum is a final, closed family whose only members are the cases you declared. Iteration comes built in: OrderStatus::cases() returns all cases in declaration order, which builds dropdowns without a config array:
foreach (OrderStatus::cases() as $case) {
printf("<option value='%s'>%s</option>\n", $case->value, ucfirst($case->name));
}
Both name (the case identifier, e.g. 'Paid') and, on backed enums, value are read-only properties available on every case. The next chapter takes these values across the process boundary — where the real migration work happens.
The migration itself is mechanical once the type exists, and worth doing in three steps: declare the enum, replace constant references with cases at every write site, and put a converter — from or tryFrom — at every read site where data enters. Each replaced == becomes === against a case, which also kills the silent coercions the string version tolerated: 'paid' == true was a landmine nobody defused, while OrderStatus::Paid === true is simply false. Static analysis earns its keep during the window when both forms coexist — PHPStan and Psalm infer enum types from constructors and signatures, flagging raw strings that still reach enum-typed parameters before runtime does. And because cases are objects, they slot into PHP's object-aware structures without help: SplObjectStorage and WeakMap accept them as keys, and any case may appear in constant expressions such as parameter defaults and class constants.
Where Reusing Behavior with Traits spread behavior sideways across classes, enums make the opposite move: they collapse a scattered set of values into one closed type that behavior can attach to.
Backed Enums on the Wire: from(), tryFrom(), and Serialization
Data arrives from HTTP bodies, queue messages, and database rows as scalars — the enum must meet it there. Backed enums get two generated static converters, and the difference between them is the difference between a bug report and a crash:
// from(): strict — unknown value throws ValueError
OrderStatus::from('paid'); // OrderStatus::Paid
OrderStatus::from('pended'); // ValueError: "pended" is not a valid backing value for enum "OrderStatus"
// tryFrom(): returns null for unknown values
OrderStatus::tryFrom('pended'); // null
$order->status = OrderStatus::tryFrom($raw) ?? OrderStatus::Pending;
Pick by trust level: from() when the scalar comes from your own system and a mismatch is a stop-the-world corruption worth crashing on; tryFrom() for untrusted input where you want a deliberate default or a validation error. Both are generated by the engine — defining your own from() or tryFrom() on a backed enum is a fatal error, as is defining your own cases() on any enum.
Serialization is where backed enums earn the backed half of their name. json_encode(OrderStatus::Paid) emits the plain backing value — "paid" — so an API response containing an enum needs no mapping layer at the edge. The inverse works through from() when parsing requests. Two sharp edges: a pure enum throws on json_encode (Cannot serialize pure enums to JSON — there is no scalar to emit) unless it implements JsonSerializable, and any enum may override the default by implementing JsonSerializable::jsonSerialize() when the wire format should differ from the backing value:
enum OrderStatus: string implements \JsonSerializable
{
case Paid = 'paid';
public function jsonSerialize(): mixed
{
return ['code' => $this->value, 'label' => ucfirst($this->value)];
}
}
PHP's own serialize() uses a dedicated format — E:11:"OrderStatus:Paid" — that restores the same singleton on unserialize(), so identity survives the round trip: unserialize(serialize(OrderStatus::Paid)) === OrderStatus::Paid is true.
For storage, the backing value is the column value: store $status->value in a VARCHAR/ENUM column and rebuild with OrderStatus::from($row['status']) on read. The from() call is your corruption detector — a legacy row with 'pended' raises ValueError at the hydration seam instead of leaking a wrong string into three business branches, which is precisely the failure the constants version could never catch. And when logic must branch on the value, PHP's exhaustive matcher refuses to guess.
Two conversion edge cases settle most production questions. Under strict_types, from() and tryFrom() demand the exact backing type: an int passed to a string-backed enum is a TypeError, not a coercion — so hydrating from database drivers that return strings for integer columns means casting explicitly, and the cast belongs at the hydration seam where a reviewer can see it. Without strict_types, PHP applies its usual weak coercions and floats round into ints — a mode worth avoiding for code that touches money or identifiers. Enum cases also compare by identity everywhere, which makes match arms and === total operations with no hashing or ordering surprises, and means two pieces of code holding OrderStatus::Paid always hold the identical singleton within a process. Where enums do not help: sets of values (no bit flags yet), values with per-case payloads (that is the planned algebraic-data-type future), and anything needing per-instance state — classes remain the answer there.
Methods, Interfaces, and match: Attaching Behavior to Values
An enum is not only a value — it is a class with behavior attached to each case, and $this inside an enum method is the case itself:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Canceled = 'canceled';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Paid — preparing',
self::Shipped => 'On its way',
self::Canceled => 'Canceled by customer',
};
}
public function canCancel(): bool
{
return $this === self::Pending || $this === self::Paid;
}
}
The match expression is the enum's native control structure: it compares with ===, and if no arm matches and there is no default, it throws UnhandledMatchError at runtime. That turns "did I handle every status?" from a test-coverage question into a runtime guarantee — add a fifth case and every non-defaulting match across your codebase announces exactly where it needs attention. (match differs from switch: it is an expression that returns a value, uses strict comparison, and does not fall through between arms.)
Enum methods may also implement an interface, which lets one call site accept values from totally unrelated enums:
interface Labelable { public function label(): string; }
enum OrderStatus: string implements Labelable { /* label() as above */ }
enum TicketPriority: string implements Labelable
{
case High = 'high';
public function label(): string { return 'HIGH — escalate now'; }
}
function renderLabel(Labelable $thing): string { return $thing->label(); }
renderLabel(OrderStatus::Paid); // works
renderLabel(TicketPriority::High); // works — different enum, same contract
The full object toolkit applies within these limits: methods may be public, protected, or private (with no inheritance, protected and private are equivalent in practice); static methods serve as named constructors (OrderStatus::fromDatabase($row) wrapping tryFrom); constants and traits without properties are allowed. What is off-limits is everything that implies per-case state — enums cannot declare properties at all, static or instance — plus constructors, destructors, cloning, inheritance, and most magic methods (__invoke, __call, and __callStatic are the exceptions). The rule behind the list: an enum case is a stateless singleton, so anything that would give it mutable state belongs in a method's local variables or in a real class. That statelessness is also why enums slot cleanly into match, WeakMap keys, and constant expressions (public const DEFAULT = OrderStatus::Pending; is legal anywhere a constant is). When your value set needs per-value data rather than per-value behavior, you have outgrown enums — and the interception tools for objects with real state are Magic Methods like __get and __toString.
Testing enums is pleasantly boring, which is the point. Cases need no mocking — they are already singletons — so a test asserting a status transition simply passes OrderStatus::Paid and asserts on the returned case with ===. The suite work moves to the transitions themselves: a parameterized test iterating cases() can prove that every status produces a label and a valid next-status set, so adding a case surfaces any missing behavior as a test failure rather than a production UnhandledMatchError. That iteration pattern — foreach over cases() with one assertion block — is also the fastest documentation generator you will find: the label() method powering an admin dropdown is the same method the test suite proved exhaustive.
Quiz
1. What does OrderStatus::from('pended') do when no case has the backing value 'pended'?
2. What does json_encode(OrderStatus::Paid) emit for a string-backed enum, and what about a pure enum?
3. Which statement about enum members is true in PHP 8.1+?
4. A match over OrderStatus has no default and no arm for a newly added case. What happens at runtime?
Flashcards
Question
What guarantees do enum cases give you that string constants never did?
Click to reveal answer
Answer
A closed set of singleton values with a real type: functions typed OrderStatus reject raw strings with TypeError; OrderStatus::Paid === OrderStatus::Paid is identity-true; new/clone are Errors, so no ad-hoc values can be smuggled in. Constants document the set; enums enforce it.
Question
from() vs tryFrom() vs cases(), name, and value — the generated enum API
Click to reveal answer
Answer
from(scalar) returns the matching case or throws ValueError (trusted input); tryFrom(scalar) returns null instead (untrusted input); cases() lists all cases in declaration order. Every case has a read-only name; backed cases add a read-only value. All are engine-generated — redefining from/tryFrom/cases is a fatal error.
Question
How do enums cross serialization boundaries?
Click to reveal answer
Answer
Backed enums json_encode to their scalar ("paid") and rebuild via from(); pure enums throw on json_encode unless JsonSerializable. PHP serialize() uses 'E:11:"OrderStatus:Paid"' and restores the same singleton, so === identity survives. In databases, store ->value and treat from() as a corruption check on read.
Question
What can an enum contain, and what is permanently forbidden?
Click to reveal answer
Answer
Allowed: constants, public/protected/private methods, static methods (named constructors), interface implementations, and property-less traits. Forbidden: properties of any kind, constructors/destructors, cloning, inheritance, instantiation, and all magic methods except __invoke, __call, and __callStatic.
Question
Why pair enums with match, and what does match do on a miss?
Click to reveal answer
Answer
match compares cases with ===, is an expression (no fall-through), and with no default and no matching arm throws UnhandledMatchError — so adding a case flags every code path that has not handled it. Enums are singletons, which is what makes the identity comparison total.
Revision Notes
Key Takeaways
- 1. An enum makes the value set closed and typed: singleton cases, TypeError on raw scalars, no instantiation or cloning — constants only documented the rules, enums enforce them
- 2. Backed enums own their serialization story: ->value for databases and APIs, from() as a strict corruption check, tryFrom() for untrusted input
- 3. from/tryFrom/cases are engine-generated; a pure enum throws on json_encode, and JsonSerializable overrides the wire format for any enum
- 4. Behavior attaches directly: methods see $this as the case, interfaces let unrelated enums share call sites, and match makes handling exhaustive at runtime
- 5. Enums are stateless singletons — no properties, ever — so data-per-value means you have outgrown the type
Interview Tips
- • "Why enums over class constants?" — constants are open strings anyone can pass; enums are closed types the engine checks, with singletons, generated converters, and exhaustive match — the answer is 'making invalid states unrepresentable'
- • "What does json_encode do with an enum?" — backed: the backing scalar; pure: throws unless JsonSerializable; add that serialize() preserves singleton identity with the E: format
- • "from or tryFrom?" — trust decision: from() crashes on unknown values (internal data, fail loudly); tryFrom() returns null for untrusted input where you want a default or a validation error
- • "Can an enum hold state or be extended?" — no properties at all and no inheritance; cases are stateless singletons. Per-value data means a class; per-value behavior plus a fixed set means enum
Cheat Sheet
Backed Enums (PHP 8.1)
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Paid — preparing',
};
}
}
OrderStatus::Paid === OrderStatus::Paid; // singleton identity
OrderStatus::from('paid'); // ValueError on unknown
OrderStatus::tryFrom($raw) ?? OrderStatus::Pending;
OrderStatus::cases(); // all cases, declaration order
OrderStatus::Paid->value; // 'paid' (backed only)
OrderStatus::Paid->name; // 'Paid'
Backing rules: one type per enum (string or int), unique literal values, no auto-numbering. Pure enums have no value and throw on json_encode (unless JsonSerializable).
Round trips: json_encode → backing scalar; store ->value in the DB, rebuild with from() and treat ValueError as data corruption. serialize() emits E:11:"OrderStatus:Paid" and restores the same singleton.
Allowed / forbidden:
| Allowed | Forbidden |
|---|---|
| constants, methods, static methods | properties (static or instance) |
| interfaces, property-less traits | constructors, destructors, clone |
__invoke, __call, __callStatic |
inheritance, instantiation |
Branching: match ($status) with === arms; no match and no default → UnhandledMatchError.