← Blog

PHP

PHP-migratie: een ID met voornullen kostte twaalf dagen

Dag twaalf van de migratie. Het hoofd kwaliteit staart naar 2.840 EN 9100-records die niet matchen. De bug: één Prisma-annotatie en een ongedocumenteerde checksum.

Jacob Molkenboer· Oprichter · A Brand New Company· 19 jun 2026· 9 min
Open leren grootboek met geïnkte nummers, messing paperclip, groen tabblad, crème kaartje op ivoorkleurig papier.

Het is de tweede vrijdag van de migratie. Marleen, hoofd kwaliteit bij een Rotterdamse toeleverancier (dertig man, lucht- en ruimtevaart plus offshore), heeft de monitor van een engineer geleend en staart naar een queryresultaat. 2.840 rijen. Geen enkele matcht met het legacy-bestand. Geen enkele. Over zes weken komt de TÜV-audit. Het PHP 5.5-portaal dat ze vervangt ging live in 2007 en draagt sindsdien stilletjes de EN 9100-traceerbaarheid. We zitten op dag twaalf van een migratie die we op tien dagen hadden begroot.

De bug, toen we hem eindelijk te pakken hadden, bleek één regel in een Prisma-schema plus een checksumfunctie waarvan de auteur in 2014 het bedrijf verliet. Dit is het verhaal.

Een lasnaad-keuringsregister, geschreven voor NodeJS bestond

Het lasnaad-keuringsregister is de ruggengraat van EN 9100-traceerbaarheid bij dit soort toeleveranciers. Elke röntgenfilm van elke lasnaad krijgt een identificatienummer. Verlies de link tussen een film en de lasnaad die hij documenteert, en de hele batch afgewerkte componenten moet opnieuw gekeurd worden. Of weg. Bij een offshore-riserflens betekent ‘weg’ dat de order weg is en de klant ook.

Het portaal uit 2007 sloeg die nummers op als Oracle VARCHAR2(14). Zonder de streepjes (die zaten alleen in de UI, niet in de database) zien ze er zo uit:

07 2024 001109
               ^^ checksum (mod-100 over de 12 voorste cijfers)
        ^^^^ volgnummer binnen ploeg
   ^^^^ jaar
^^ labcode (07 = doka Spaanse Polder)

De canonieke id was veertien cijfers tekst met voornullen. De voornul bij labcode 07 was geen opmaak. Hij wás de labcode. 7 was een ander lab, drie gebouwen verderop, met een ander hoofd keuring en een andere ploeg gecertificeerde technici.

De checksum was een stille modulo-100 over de eerste twaalf cijfers, afwisselend gewogen met 3 en 1. De engineer die hem schreef leende het idee van EAN-13 en paste het aan voor een veld van veertien tekens. Hij documenteerde het nergens, behalve in een PHP-functie genaamd filmcode_klopt(). Die functie vonden we pas op dag elf.

Hoe Prisma's introspector stilletjes loog

Het migratieplan was op papier rechttoe rechtaan. Postgres 16 opzetten, twee weken dual-write vanuit de PHP-applicatie, de NestJS-read-API achter dezelfde login bouwen, en in één weekend het verkeer omschakelen. Wat we onderschatten: het overzetten van het schema.

We draaiden prisma db pull tegen de staging-Oracle om een beginschema te krijgen. De introspector zag VARCHAR2(14) op de kolom met het film-id en mapte hem terecht op String. Tot zover prima.

Toen ruimde een goedbedoelende engineer het schema op.

// voor -- wat prisma db pull opleverde
model RadiographicFilm {
  id        String   @id @db.VarChar(14)
  weldId    String   @db.VarChar(20)
  shotAt    DateTime
  // ...
}

// na -- wat in main belandde, de dag voor de cutover
model RadiographicFilm {
  id        BigInt   @id
  weldId    String   @db.VarChar(20)
  shotAt    DateTime
  // ...
}

De redenering in de pull request klonk logisch: “waardes zijn allemaal cijfers, BigInt is sneller op joins, scheelt indexgrootte.” De reviewer keurde 'm goed. CI ging groen. Het seed-script liep groen tegen staging, want staging was opnieuw geseed vanuit een CSV die het devteam zelf in elkaar had geschoven — en Excel had zes maanden eerder stilletjes de voornullen gestript. Als je seed-data een CSV is die ooit in Excel geopend is, beschouw die dan als gecompromitteerd tot je de originele bron hebt nagekeken.

De dag dat productiedata het nieuwe schema raakte

Voor de generale dumpten we direct vanuit Oracle naar Postgres. De Oracle-export was correct: elke id was de volle veertien tekens, voornullen intact. De Prisma-client ontving ze als strings, riep intern BigInt(value) aan en schreef ze weg als NUMERIC. Postgres deed precies wat je verwacht. Hij sloeg het getal op.

00072024001109 werd 72024001109. Op de terugweg door de API padde de formatter weer aan tot veertien tekens — met voornullen op een vaste breedte. Een record van lab 13 dat oorspronkelijk 13072024001109 was, kwam ongewijzigd terug. Een record van lab 07 kwam terug als 00072024001109, wat er goed uitziet, tot je de checksum controleert.

De checksum wordt berekend over de eerste twaalf tekens van de canonieke id. Bij een film van lab 07 bevatten die twaalf tekens de voornul. Bij een record dat de heen-en-terugreis door BigInt heeft gemaakt, staat de oorspronkelijke voornul er nog — maar voor elke film waarvan de middelste cijfers toevallig met een nul begonnen, schoof het strippen door BigInt plus het opnieuw padden op vaste breedte de labcode in het verkeerde vakje. 2.840 van de 41.200 records hadden die vorm. De checksum faalde bij elk daarvan.

De downstream-query die telt is het EN 9100-traceerbaarheidsrapport. Die joint films aan lasnaden aan batches aan vrachtbrieven op id, en de auditor loopt een steekproef achterstevoren door die ketting. Een film waarvan de id niet meer matcht met het lasnaad-record is, voor audit-doeleinden, een wees-lasnaad zonder radiografisch bewijs. 2.840 daarvan is geen defect. Dat is een bedrijf dat zijn certificering kwijtraakt.

Twaalf dagen op de verkeerde plek zoeken

Dag een tot en met zes waren we ervan overtuigd dat de bug in de Oracle-export zat. Niet dus. Dag zeven tot en met negen verdachten we de dual-write-bridge van dubbel encoderen. Niet dus. Op dag tien zag een engineer dat de id van elk falend record met 0 begon. Op dag elf vonden we filmcode_klopt() in /legacy/inc/laskeuring.inc.php, achtendertig regels inclusief commentaarblok, en begrepen we dat we stilletjes een gestructureerd identificatienummer als getal hadden hergecodeerd.

De fix kostte vier uur.

model RadiographicFilm {
  id        String   @id @db.Char(14)
  weldId    String   @db.VarChar(20)
  shotAt    DateTime
  // ...
  @@index([weldId])
}

@db.Char(14) in plaats van @db.VarChar(14), omdat Postgres CHAR writes weigert die niet exact de opgegeven breedte hebben, en we wilden dat elke stille truncatie zou crashen in plaats van te padden. De Postgres-documentatie over character-types legt het verschil duidelijk genoeg uit dat we 'm op dag één hadden moeten lezen.

De checksum-verifier porten we naar TypeScript, byte voor byte, met een unit test tegen vijfentwintig bewezen-correcte ids uit de productie-export van 2007:

// libs/laskeuring/src/checksum.ts
export function filmCodeIsValid(id: string): boolean {
  if (id.length !== 14 || !/^\d{14}$/.test(id)) return false;
  const body = id.slice(0, 12);
  const expected = id.slice(12);
  const sum = body
    .split('')
    .reduce(
      (acc, ch, i) => acc + Number(ch) * (i % 2 === 0 ? 3 : 1),
      0,
    );
  const computed = String((100 - (sum % 100)) % 100).padStart(2, '0');
  return computed === expected;
}

Een reconciliatiescript liep de 2.840 kapotte rijen langs, leidde de originele id af uit de omliggende context (labcode staat op het lasnaad-record, jaar op de werkorder, het volgnummer-binnen-ploeg stond nog in de dump), berekende de checksum opnieuw en schreef een gecorrigeerde rij weg samen met een audit-spoor. Elke gecorrigeerde rij kreeg een regel in migration_correction_log met de oude id, de nieuwe id, de reden en een SHA van de bronrij uit de Oracle-dump. De TÜV-auditor vroeg om die tabel bij de volgende audit. Hij kreeg 'm.

Wat we in het draaiboek veranderden

Drie dingen, op volgorde van hoeveel pijn ze ons bespaard zouden hebben.

Audit de waardes, niet alleen de kolomtypes. Draai voor elke schemaport een query van vijf minuten die de kolom groepeert op lengte, op eerste teken en op tekenklasse. Een kolom van VARCHAR2(14) waarvan de waardes uitsluitend uit cijfers bestaan, is geen getallenkolom. Het is een stringkolom waarvan de waardes toevallig cijfers zijn, en in negentig procent van de gevallen dragen de voorste tekens betekenis.

Lees de legacy-code voor je het legacy-schema vertrouwt. Het schema vertelde ons dat de kolom een string van veertien tekens was. De PHP vertelde ons dat het een gestructureerd identificatienummer met een ingebakken checksum was. De PHP had gelijk. grep -ri 'checksum\|klopt\|valid' legacy/ op dag één had ons negen dagen en één heel lang gesprek met het hoofd kwaliteit gescheeld.

Gebruik nooit BigInt voor een domein-identifier, ook niet als de waardes numeriek lijken. Domein-identifiers zijn strings. Altijd. Het argument over indexgrootte overleeft het contact met een echte audit niet. De Prisma native-type reference maakt @db.Char(n) de voor de hand liggende keuze voor opaque identifiers met vaste breedte. Wij praatten onszelf eruit omdat de waardes op getallen leken en de cutover over drie dagen was.

Onthoud

Beschouw elke string van cijfers met vaste breedte in een legacy-schema als schuldig aan het dragen van structuur, totdat de legacy-code het tegendeel bewijst.

Wat we uiteindelijk hebben opgeleverd

De cutover was drie weken later dan gepland. De NestJS-API bedient nu het traceerbaarheidsportaal voor keurmeesters in de werkplaats, de auditvoorbereiding van het kwaliteitsteam, en een tablet-app waarmee de lassers buiten op het terrein filmbarcodes scannen naar de juiste lasnaad. De reconciliatie-log blijft voor onbepaalde tijd in productie. Elk gemigreerd record heeft een SHA-spoor terug naar zijn oorsprong in Oracle uit 2007. Toen de TÜV-auditor het jaar erna terugkwam, nam hij veertien records als steekproef en liep ze allemaal achterstevoren door zonder ergens een breuk te vinden.

Toen we voor deze klant de migratie van het certificeringsportaal bouwden, hielden we één kleine, specifieke les over: de legacy-code is de echte documentatie van het schema, en die lees je voor je Prisma aanraakt. Die les nemen we mee in elke legacy-migratie die we nu oppakken — het kost een middagje lezen en scheelt de soort week die eindigt met een hoofd kwaliteit dat naar 2.840 kapotte rijen staart.

Sta je op het punt om aan zo'n port te beginnen, draai dan eerst deze query: SELECT LENGTH(id), SUBSTR(id, 1, 1), COUNT(*) FROM your_legacy_table GROUP BY 1, 2 ORDER BY 1, 2;. Is het eerste teken niet uniform verdeeld, dan draagt dat teken betekenis. Behandel de kolom als een string.

Kern

Een string van cijfers met vaste breedte in een legacy-schema is bijna nooit een getal. Lees de legacy-code voor je Prisma laat bepalen wat de kolom betekent.

FAQ

Waarom heeft Prisma stilletjes een string naar BigInt gecast?

Dat heeft hij niet. De introspector produceerde @db.VarChar(14) gemapt op String. Een latere code review veranderde het type naar BigInt, in de aanname dat veertien cijfers een getal was. Die review had geblokkeerd moeten worden.

Was het oorspronkelijke portaal uit 2007 fout om VARCHAR2(14) te gebruiken?

Nee. De engineer die het ontwierp modelleerde terecht een gestructureerd identificatienummer met ingebakken betekenis als een string van vaste breedte. De fout was van ons, vijftien jaar later, door zijn strings als getallen te behandelen.

Verschilt Postgres CHAR in dit geval echt van VARCHAR?

Ja. CHAR(14) weigert waardes die niet exact veertien tekens lang zijn, en dat was wat we wilden. VARCHAR(14) accepteert kortere strings stilletjes, en zo kan een slechte fix de oorspronkelijke bug onzichtbaar houden.

Hoe voorkom je dit bij de volgende migratie?

Audit de waardes, niet alleen het kolomtype. Een query van vijf minuten die een kolom met cijfers groepeert op lengte en eerste teken vertelt je of de voornullen betekenis dragen. Lees de legacy-code voor je het schema vertrouwt.

migrationlegacy sitesphparchitecturecase study

Iets bouwen?

Start een project