ADR-0051: Enums on Database Columns Are Int-Backed
Accepted Cross-ProjectAccepted 2026-09-27 (Commander rulings on LK-0967, recorded on the ticket). The Commander ruled that lokalekeuze moves every enum cast onto a database column to an int backing, which is the convention emmie and kendo already hold. He then asked for this ADR, so that the convention reaches the Foundry (
laravel-skeleton) and does not live only in one territory's plan directory. How far it reaches into other existing territories is listed under What needs deciding; it is not decided here.
Date: 2026-09-27
Context
The defect that started it. lokalekeuze report #242 (LK-0967): products.status was a varchar under MySQL's default utf8mb4_unicode_ci, which is case-insensitive and PAD SPACE. So where('products.status', ProductStatus::Live) also matched a row holding 'LIVE' or 'live ', and that row appeared in the public catalogue. The class covered every enum where in the codebase: 24 enum-cast string columns on 20 tables at the time.
The first ruling, and why it was revised the same day. The first ruling was a binary column collation. Two things came out of building it:
- The collation the ruling named was wrong.
utf8mb4_binis PAD SPACE, so'live' = 'live 'still matches under it. Onlyutf8mb4_0900_bin(NO PAD) is byte-exact. It was measured on MySQL 8.0, and the repo's own migrations (LK-0146, LK-0340) already said so. A ruling that names a mechanism is a claim like any other. - The Commander then asked for the fleet's convention instead. emmie holds 41 int-backed enums and 30 string ones; kendo holds 24 and 14. A collation fixes the comparison. It keeps the strings, and so the divergence from the two territories with the longest history.
What an int backing costs, measured on lokalekeuze at the time of the ruling:
- 70 string-backed enums, and 672
->valueuses inapp/, 243 of them in the HTTP layer. - 37 frontend files narrowing on status literals.
- 19 migrations with CHECK constraints or generated columns spelled in the words.
- Hash-chained audit details holding the words.
Production was not yet provisioned, so the data migration was cheap. The code blast radius was not.
Why the Foundry matters. laravel-skeleton casts every new Laravel territory, and it was genericised from lokalekeuze. A convention that exists only in the donor's docs/plans/ is copied by accident or not at all.
Options Considered
A. Byte-exact collation (utf8mb4_0900_bin) on string enum columns. Built first (LK-0967 collation branch, 24 columns plus a mysql-lane gate), then shelved unmerged. It fixes the comparison and nothing else, and it leaves the fleet split between two storage conventions.
B. BINARY at each comparison. A convention every call site must remember. The gate would have to recognise every query shape, which a scanner cannot promise.
C. A CHECK constraint per status column. Bad values never land, but every new case needs a migration, and sqlite and MySQL enforce CHECK differently. It is also no help to rows already stored.
D. Int-backed enums on database columns. Chosen. It matches emmie and kendo. An integer column has no collation to get wrong, and the enum cast is the only writer.
Decision
1. A backed enum cast onto a database column is int-backed
A new enum column is an int-backed enum. A string-backed enum on a column is a ruling, never a default. It is recorded as a named survivor with its reason.
Two survivor classes are ruled today:
- A vocabulary owned by an external system. SnelStart's ledger function and VAT kinds are the words SnelStart's API speaks; an int backing would add a translation at the boundary for no gain.
- An audit actor in a hash-chained, append-only table.
AuditActorTypeis onaudit_entries.actor_typeandprint_exports.requested_by_type, and rewriting stored values would break chain verification.
A survivor column carries utf8mb4_0900_bin, never utf8mb4_bin.
2. The API emits the int; the frontend mirrors it
A resource emits ->value, which is the int. The frontend keeps one mirror per converted enum: a numeric const map in shared/enums/, plus a membership guard wherever a code arrives from the API. A parity spec holds every mirror to its PHP enum, by value and in case order.
A public URL keeps its human word. Where a word appeared in a page URL or a public request, the frontend maps word ↔ code. Changing a public or bookmarkable URL is a ruling, not an engineering choice.
3. Audit details carry a frozen, declared word — never the code
An audit detail records the enum as a word. Each case declares that word explicitly (App\Contracts\DeclaresAuditTerm::auditTerm(), an exhaustive match). It is exactly the string the column stored before its conversion, frozen: e.g. FaqAudience::Voucherholder declares 'voucherhouder', and CreditNoteSyncStatus::PeriodLocked declares 'period_locked'. It is not the case name.
Old and new audit rows therefore speak one vocabulary forever, the hash chain is never rewritten, and a later case rename cannot silently change what the record says. Editing a declared word is a ruling.
4. The conversion migration is locked and re-runnable
A string → int conversion runs these steps, under LOCK TABLES <table> WRITE on MySQL, released in a finally:
- Refuse any row that is not byte-exactly one of the enum's words (counted under
utf8mb4_0900_bin), naming table, column and count, before anything is written. - Map each word to its code.
- Retype the column to
unsignedTinyInteger.
The migration is re-runnable: on a string column it accepts codes already spelled as strings, and on a column already an integer it only checks. down() retypes first, then maps back.
Why the lock is load-bearing. MySQL DDL is not transactional. An unlocked conversion lets an old-image write land a word between the mapping and the retype. The ALTER then fails and leaves a half-converted varchar. Without re-runnability, every later release's refusal then wedges on its own codes. This was measured on lokalekeuze slice 3 (a private crit round grounded it, and both the race and the wedge were reproduced), and it is recorded there as LK-0967 D23, amending D11.
5. Laravel binds only string-backed enums from a route
Implicit route binding filters on string backing, so an int-backed enum route parameter is left unbound and every request 500s. A controller resolves the code itself (Enum::tryFrom((int) $segment) → 404 on a miss), behind a numeric route pattern (e.g. [1-9][0-9]{0,2}).
Enforcement
On lokalekeuze, where the mechanism was built:
| Level | Guard | Holds |
|---|---|---|
| 1 | EnumColumnBackingTest | The census comes from every model's getCasts(). Every enum-cast column must be int-backed, a named survivor from a CLOSED roster, or on a shrink-only awaiting-conversion list. Every coded enum must declare its audit words, and EnumColumnCensus::AUDIT_WORDS pins each one. |
| 1 | AuditDetailEnumValueTest | No audit detail carries an int-backed enum's raw ->value, and a receiver it cannot type is refused. |
| 1 (mysql lane) | EnumColumnStorageTest | Int columns are integer typed and survivors are utf8mb4_0900_bin. Each conversion also carries a refuse → convert → round-trip case, a re-run case over a half-converted column, and a race case with a second connection against the lock. |
| 1 (frontend) | int-enum-parity.spec.ts | One mirror per coded enum, by value and in case order. |
Fleet placement is open (see What needs deciding). The standing ruling of 2026-09-22 prefers a phpstan-warroom-rules rule over per-territory arch tests for fleet enforcement. The census gate is a candidate for that promotion, and not before a second territory needs it.
Consequences
- A conversion is a cross-stack contract change. A backend-first cut breaks every screen whose store guards the word, and CI stays green because the store specs mock HTTP. So each slice lands whole. On lokalekeuze that tripped the PR-size gate's 30-file axis three times at 449–883 lines. For the large enums (the mail trio, voucher, merchant, product, campaign and choice statuses), plan a behaviour-neutral prep PR on the still-string enum first:
DeclaresAuditTerm,AuditTerm::of()routing andStoredEnumnarrowing, all output-identical while value equals word. Do not keep overriding the gate. - The deploy window (staging, and production once it is live): while
release_commandmigrates, the old image waits on the lock, then meets the integer column. Its writes are refused under strict mode, and its reads fail their string narrowing. A browser on an old bundle sees its failure state until a reload. This is accepted pre-launch. Once production serves traffic, a conversion needs an expand/contract shape instead, which is open. - Tests that planted words go quiet rather than red. A "sorts before" string plant or a membership refusal proved with a word passes for the wrong reason against an integer column. Every slice greps
tests/for the column name and plants codes.
Implementation
| Territory | State | Notes |
|---|---|---|
| lokalekeuze | In Progress | LK-0967. Slices merged 2026-09-27: #1141 (foundation + AdminInvitationSendStatus), #1144 (employers), #1146 (FaqAudience), #1148 (credit_notes pair), #1150 (RejectionReasonScope). 12 columns await conversion. Survivors: 5 columns on utf8mb4_0900_bin. Plan dir: docs/plans/LK-0967-int-backed-enums/ (D8–D27). |
| laravel-skeleton | Not Started | Carries one enum, AuditActorType, a ruled survivor. To adopt: the contract, the gates and the doctrine, so a cast territory starts int. Co-owned with ally Demian, who is to be told first. Board row: WR-1722. |
| emmie | Convention held | 41 int-backed / 30 string-backed enums. Column usage not audited against this ADR. |
| kendo | Convention held | 24 int-backed / 14 string-backed enums. Column usage not audited against this ADR. |
| others | Unmeasured | — |
What needs deciding
- Reach into existing territories. Should a string enum already on a column elsewhere (emmie's 30, kendo's 14, the rest unmeasured) be retrofitted, or should the rule bind only new columns there? The General's recommendation is new columns only, plus a retrofit when a report-#242-class defect is found.
- Enforcement home. Per-territory arch tests (as on lokalekeuze), or a
phpstan-warroom-rulesrule once a second territory needs it. - Conversions after launch. An expand/contract shape (a new column, dual write, backfill, switch, drop) for a table serving live traffic.
Evidence: lokalekeuze LK-0967 (ticket comments of 2026-09-27: the collation ruling, its same-day revision, sub-rulings 1–3, and D10); docs/plans/LK-0967-int-backed-enums/DECISIONS.md D8–D27; campaign report campaigns/lokalekeuze/2026-09-27-twenty-merges-and-the-enum-campaign-that-replaced-a-collation.md.