An enum migration looked mechanical. A package-backed value object became a native PHP backed enum. The domain concept stayed the same, the new cases were easy to search, and current code wrote the canonical values.
Then an older row loaded a lowercase value. The native enum expected the same word with different casing and rejected it during hydration.
The missing caller was not in the source tree. It was sitting in the database.
The retained correction was deliberately small: normalize that known historical value at the cast boundary before strict hydration, and write the canonical form from then on. Focused tests covered reading the old value and persisting its replacement. The history does not retain how many rows were affected or whether every historical variant was found.
That narrow failure contains a general refactoring lesson. Stored data calls today’s code long after the code which produced it has disappeared.
A row can call code years after it was written
We usually draw callers as functions:
controller -> service -> parserFor a parser at a durable boundary, the useful picture is larger:
current request --------\
current worker ----------> parser -> domain value
old database row --------/
delayed message ---------/
restored archive --------/
rollback release --------/Those callers do not share a deployment. The current request was produced by today’s code. The row may have been produced by an importer from three versions ago. A delayed message may outlive both.
Changing the parser changes the contract for all of them at once.
This is why a source search can give a dangerously complete-looking answer. It can find current constructors, factories, and writers. It cannot enumerate the byte sequences already stored in a table, queue, cache snapshot, file, or backup.
The absence of direct in current source says nothing about whether direct
still exists at rest.
Equivalent types can accept different histories
Moving from a library enum to a native enum can improve type checking and remove a dependency. It can also change how input becomes a value.
Consider a simplified representation:
enum RouteKind: string
{
case Direct = 'Direct';
case Relay = 'relay';
}
$kind = RouteKind::from($storedValue);from() is intentionally strict. It accepts Direct; it rejects direct.
That is useful when unknown state should be impossible. It is not evidence
that every existing row already satisfies the new rule.
The old implementation may have accepted method aliases, labels, loose coercion, or custom mappings. The new one may accept only backing values. Both types can describe the same business idea while recognizing different historical representations.
Review the conversion in two columns:
| Question | Current code | Durable history |
|---|---|---|
| What can be written? | constructors and writers | old releases and imports |
| What can be read? | new parser | values actually at rest |
| What is canonical? | declared backing value | possibly several spellings |
| What is unknown? | rejected new input | old value or actual corruption |
The refactor is compatible only where both columns meet.
Inventory representations, not just symbols
Before replacing a parser, list every place which can retain its input:
- database columns and JSON fields;
- ready, delayed, reserved, and failed queue payloads;
- cache values which survive a release;
- object storage and generated files;
- import and export formats;
- audit records and event streams;
- backups which can be restored;
- other deployed versions which can still write; and
- administrative tools with their own release cadence.
Then collect values, counts, oldest and newest timestamps, and producers where the environment allows it. The result should look more like a data inventory than a call graph:
| Representation | Count | Newest write | Known producer | Decision |
|---|---|---|---|---|
Direct | measured | current | current writer | canonical |
direct | measured | historical | retired importer | map |
DIRECT | measured | unknown | unknown | investigate |
The words measured and unknown matter more than invented numbers. If
production cannot be queried safely, say that the inventory is incomplete and
keep the reader compatible until stronger evidence exists.
Do not turn every surprising value into compatibility. A known former representation and an unexplained malformed value are different states. The first may deserve a mapping. The second may need quarantine, repair, or an explicit error.
Put translation at the boundary which owns it
The retained correction normalized the one known historical value inside the model cast:
$canonical = match ($storedValue) {
'direct' => RouteKind::Direct->value,
default => $storedValue,
};
return RouteKind::from($canonical);That placement has two useful properties. The domain receives one canonical value, and every hydration path crosses the same translation.
Scattering the mapping through controllers, jobs, and views would preserve only the paths somebody happened to remember. Making the enum itself accept arbitrary casing would move a persistence concern into every use of the type and could hide genuinely unknown input.
The narrow adapter says something precise:
directis one recognized historical representation ofDirect.
It does not say that case never matters. It does not accept DIRECT, trim
arbitrary whitespace, or guess from a label. Compatibility should be as broad
as the evidence, not as broad as the coercion function permits.
Writes need their own rule. Once the reader accepts the historical form, every new write should emit the canonical form:
read: direct -> Direct
write: Direct -> DirectOtherwise the system can keep producing the debt it is supposed to retire.
Choose migration from lifetime and authority
A compatibility reader is not always the best answer.
A database backfill can be cleaner when one application owns all writes, the table is bounded, the update can be verified, old releases cannot return, and archives will not reintroduce the value. Migrate the rows, deploy the strict reader, and remove the temporary machinery.
A bounded reader is safer when rows are numerous, several releases coexist, queues or imports can reintroduce the old form, rollback remains possible, or the value appears in long-lived history. It lets old and new representations cross one explicit seam while the system converges.
Dual reading with canonical writing is often the practical middle:
release A: read old + new, write new
measure: old reads and old writes
migrate: remaining durable values
release B: reject old after the counters reach zeroImmediate rejection is also legitimate when the value is unsafe or cannot be interpreted reliably. The error must then lead somewhere useful: quarantine the record, expose the identifier, preserve the raw value, and provide a repair path. A crash with no context converts questionable data into an operational mystery.
The choice depends on who can still produce the old form and how long durable state lives, not on how untidy the compatibility branch looks.
Test the history on both sides of the seam
A parser refactor needs more than tests for its declared cases. Use a small contract matrix:
| Case | Read result | Next write |
|---|---|---|
| canonical value | canonical domain value | canonical value |
| known historical value | canonical domain value | canonical value |
| unknown value | explicit failure or quarantine | none |
| null, if supported | null | null |
The old-read test prevents a future cleanup from deleting the branch while the representation still exists. The canonical-write test proves that new debt is not created. The unknown-value test stops a narrow migration adapter from becoming a permissive garbage collector.
The public reproduction runs those distinctions with a synthetic native enum. Strict hydration accepts the canonical value and rejects the historical one. The compatibility reader maps only the known old form, persists the canonical form, and still rejects an unexplained variant.
That fixture proves PHP’s parsing behavior and the adapter’s boundary. It does not prove which values exist in a private database or whether a backfill is complete.
Compatibility code needs an exit condition
An unexplained branch can survive forever because nobody knows whether it is still needed. Give it enough context to become removable:
representation: direct
canonical form: Direct
known producer: retired importer
remaining rows: measured periodically
old reads: counted
old writes: counted
removal gate:
no supported producer
no remaining durable values
rollback window closed
archive and replay policy decidedTelemetry turns the branch from folklore into a migration. If old reads fall to zero but old writes continue, a producer was missed. If rows remain after writes stop, a backfill or deliberate retention decision remains. If an archive can restore them next year, zero rows today is not sufficient.
Sometimes the cheapest permanent design is to keep a tiny decoder. Long-lived audit history may be more valuable untouched than normalized, especially when the representation itself is evidence. In that case, retain the adapter, document the format lifetime, and test it as a supported reader rather than calling it temporary.
Old code is not sacred. Neither is clean code. The question is whether the branch still carries a contract which durable state can invoke.
Delete it when measurement shows that no supported producer or stored value can call it again. Until then, the branch is not dead code. It is a reader for history.