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. It was exactly the kind of refactor that looks finished after a clean source search.
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, quietly waiting for the new code to read it.
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. Git cannot show that caller because Git does not own the history that matters here.
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. Only one of them appears in the pull request.
This is why a source search can give a dangerously complete-looking answer. It can find current constructors, factories, and writers, then confidently report zero old references. 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 type system can be
right while the migration is still wrong.
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. Current writers prove what happens next; durable history proves what still has to load.
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. Guessing that “it
was probably only a few rows” is not a migration strategy.
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. One known historical spelling does not justify a case-insensitive garbage chute.
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. A compatibility reader paired with a legacy writer is just permanent dual format.
Choose migration from lifetime and authority
A compatibility reader is not always the best answer. Sometimes the cleanest solution really is to update the rows and delete the branch; the important part is proving the old form cannot come back.
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 and leaves support to rediscover the raw value later.
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. All three matter; a happy-path enum test covers only the least interesting one.
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. Then every cleanup either keeps folklore or risks breaking old data. Give the branch 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. Deleting a three-line compatibility mapping is not a win if it makes a million old rows unreadable.
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.