A PHP app stores order statuses as a string-backed enum in the database; what breaks when you rename a case, change a value or add a case?
answer
- store ->value, never ->name
- renamed case: code and serialized data break
- changed value: from() throws on old rows
- tryFrom() hides it as null
- new case: match without default throws
basics
~20 sThe stored ->value is the contract. Renaming a case breaks code and anything that stored the name; changing a value makes from() throw ValueError on old rows; adding a case makes every default-less match throw UnhandledMatchError until handled.
solid answer
~40 sPersist `->value`, never `->name`, and treat values as a contract shared with the database, caches, queues and API clients. **Renaming a case** (`Placed` to `Received`) leaves stored values intact, but every `OrderStatus::Placed` reference throws `Error: Undefined constant` when it runs, and serialized payloads, which store the case *name*, stop unserializing. **Changing a value** (`'out_for_delivery'` to `'dispatched'`) orphans existing rows: `from()` throws `ValueError` on load, while `tryFrom()` quietly returns `null` and can turn into lost data; it needs a data migration in the same deploy, plus a plan for in-flight messages. **Adding a case** is safest for storage, but every `match` without `default` throws `UnhandledMatchError` when the new case reaches it, and any column constraint must allow the value. Load stored data with `from()` so drift fails loudly.
go deeper
Know that the backing value is what gets stored, and that from() throws when a stored value no longer matches a case.
Explain what breaks for renames, value changes and new cases, including UnhandledMatchError and serialized names.
Plan enum changes as data migrations with transition windows, and load stored data strictly so drift fails loudly.
Govern backing values as a cross-system contract, with rules for deprecation, transition periods and consumers you do not control.
## Two identities per case A backed case has two identifiers, and they live in different places: - The **case name** (`OrderStatus::OutForDelivery`, `->name`) lives in **code**. - The **backing value** (`'out_for_delivery'`, `->value`) lives in **data**: database rows, cache entries, queue messages, JSON sent to apps and partners. The rule that keeps evolution manageable: **persist `->value`, never `->name`**. Then the name is free to change with a refactoring, and the value becomes the stable contract. ## Renaming a case Changing `case Placed = 'placed'` to `case Received = 'placed'`: 1. Stored rows are unaffected; `from('placed')` now returns `Received`. 2. Every remaining `OrderStatus::Placed` in code fails with `Error: Undefined constant OrderStatus::Placed` **when that line runs**, not when the file compiles. Static analysis or a full test run catches them; a rarely used branch may not be hit for weeks otherwise. 3. Anything that stored the **name** breaks: PHP's native `serialize()` records enum cases by name, so cached or queued serialized objects fail to unserialize, as does any column or log that stored `->name`. ## Changing a backing value Changing `'out_for_delivery'` to `'dispatched'` is a **data migration**, not a refactoring: - Existing rows still contain `'out_for_delivery'`. Loading them with `from()` throws `ValueError: "out_for_delivery" is not a valid backing value for enum OrderStatus`. - Loading them with `tryFrom()` returns `null`. If the code then falls back to a default (`?? OrderStatus::Placed`), orders silently jump back to an earlier state, and the next save writes the wrong value: **silent data corruption**. - Messages already in a queue and mobile apps still sending the old value keep arriving after the deploy. A safe sequence: | Step | Change | |---|---| | 1 | Accept both values on input: a static `fromStored(string $v)` maps the old value to the case | | 2 | Migrate stored rows to the new value | | 3 | Switch writes to the new value (the case's `value`) | | 4 | Remove the legacy mapping once old messages and clients are gone | Often the cheapest answer is simply not to change values once they have shipped. ## Adding a case Adding `case Refunded = 'refunded'` is the most common change and the safest for storage, but it touches behaviour: - Every `match ($status)` **without** a `default` arm throws `UnhandledMatchError` when a refunded order reaches it. That is the desired outcome, because it forces each decision point to handle the new state, but only if tests or a static analyser find those `match` expressions before production does. - Methods on the enum (`label()`, `canTransitionTo()`) must include the new case. - A database column restricted to the known values (a check constraint or a native enum column type) must be widened in a migration **before** the code writes the new value. - Consumers that validate values against a list, such as a mobile app, need to tolerate an unknown status. ## Loading discipline - Data your system wrote: `from()`, so drift is an exception at the read, close to the cause. - Data from outside: `tryFrom()` with an explicit error, and a type check first on int-backed enums, since a non-numeric string throws `TypeError` even from `tryFrom()`. - Never convert `null` from `tryFrom()` into a default state without logging it. ## Tests that make evolution safe A few cheap tests catch most enum-evolution mistakes before production: - iterate `OrderStatus::cases()` and assert that `label()` and every other per-case method return a value for each case, so a new case without an arm fails in the test suite rather than in production; - assert that `OrderStatus::from($case->value) === $case` for every case, which catches duplicate or mistyped values; - keep a fixture of the values that exist in production data and assert each still maps with `from()`, which turns an accidental value change into a failing test. ## Summary - Names are code; values are data. Store values. - Renames are refactorings plus a check for anything that stored names. - Value changes are migrations with a transition window. - New cases are safe for storage if `match` expressions and constraints are updated with them.
- In PHP, why should an order record store $status->value rather than $status->name?The name is a code identifier that a refactoring may rename, while the value is declared precisely to be the stable external form. Storing the value lets you rename cases freely and keeps `from()` as the single, strict conversion on load.
- In PHP, why can tryFrom() with a default state be dangerous when loading stored statuses?An unknown stored value then maps silently to the default, so an order can appear to move back to an earlier state, and the next save overwrites the real value. For data your system wrote, `from()` fails loudly at the read instead.
saying these in an interview costs you the question
- Stores ->name in the database because it reads better
- Changes a backing value without migrating existing rows
- Loads stored data with tryFrom() and falls back to a default silently
- Adds a default arm to every match so new cases never throw
- Assumes PHP reports a missing enum case at compile time