← Blog

PHP

PHP 5.6 ERP-migratie: hex-gewichten en een 32k-plafond

Een PHP 5.6 ERP-migratie naar Directus liep veertien dagen vast toen hex-gewichten uit 1998 stilletjes afkapten op 32,767 kg — en 4.200 kaaspartijen sneuvelden.

Jacob Molkenboer· Oprichter · A Brand New Company· 18 jun 2026· 9 min
Koperen weegschaal op ivoorpapier met ijzeren gewicht, groen label aan touw, crème kaart met rode lakzegel.

Op dag vijftien van een migratievenster van veertien dagen zat onze projectlead Marije aan een magazijnbureau in Gouda en zag de touchscreen-weegschaal 47,2 kg aanwijzen. Het nieuwe ERP — net overgezet van een eigen PHP 5.6 + MySQL 5.1-portaal naar een Directus + Nuxt-stack — registreerde dezelfde palletafboeking als 32,767 kg. Hetzelfde getal, elke keer, wat ze ook boven de 32 kilo op de weegschaal legden.

De klant is een kaasgroothandel van 29 mensen die Gouda, Edam en Boerenkaas levert aan retailers in de Benelux. Hun oude portaal draait sinds 1998. De migratie zou twee weken duren. We zaten nu op twee weken plus één dag, en 4.200 records voor partij-traceabiliteit klopten in stilte niet.

Het voorraadgrootboek uit 1998

De tabel voorraad_correctie in het portaal — voorraadcorrecties, het grootboek dat elke gram bijhoudt die tussen inname en verzending uit een partij wordt toegevoegd of weggehaald — was ontworpen door iemand die in 1998 precies wist wat hij deed en precies niets over wat 2026 nodig zou hebben.

Het schema, onaangeraakt sinds de eerste commit:

CREATE TABLE voorraad_correctie (
  id        INT(11)    NOT NULL AUTO_INCREMENT,
  partij_id INT(11)    NOT NULL,
  delta_g   CHAR(8)    NOT NULL,   -- gram delta as signed hex
  reden     VARCHAR(40),
  user_id   INT(11),
  ts        DATETIME,
  PRIMARY KEY (id)
) ENGINE=MyISAM DEFAULT CHARSET=latin1;

Die CHAR(8) met een signed hex-waarde is het soort keuze dat je maakt als schijfruimte duur is, als je PHP-team een shared host van 4 MB deelt, en als je hebt besloten — om redenen die destijds hout sneden — om het signbit handmatig te encoderen. Een typische rij zag er zo uit:

delta_g = '0000FFEC'   -- -20 grams (signed 16-bit two's-complement, sign-extended)
delta_g = '00007FFF'   --  32,767 grams
delta_g = 'FFFF8001'   -- -32,767 grams

Waar de 32.767 stilletjes vandaan komt

Het originele PHP schreef die waarden met een one-liner die al sinds het kabinet-Kok in de codebase zit:

// 1998-era encoder, still in use
function pack_delta(int $grams): string {
    // pack as signed short, hex-encode, left-pad to 8 chars
    return str_pad(bin2hex(pack('s', $grams)), 8, 'F', STR_PAD_LEFT);
}

De format-vlag 's' bij pack() staat voor een signed short — een 16-bits integer. Bereik: −32.768 tot +32.767. De CHAR(8)-kolom verbergt dat volledig: van buiten lijkt het op acht hex-tekens met 32 bits ruimte, dus zou je aannemen dat correcties tot ±2 miljard gram mogelijk waren. Dat was nooit zo. De bovenste vier hex-tekens waren altijd óf 0000 (positief) óf FFFF (negatief). Het werkelijke dataplafond lag op 32,767 kg.

Voor een kaasgroothandel werkt dat bijna altijd. Een kaas Gouda weegt 4 tot 12 kg. Snij-verlies is 30 tot 200 gram. De hot path van de business — correcties op kaasniveau — past ruim binnen het 16-bits venster.

De cold path past niet. Afboekingen op palletniveau (een heftruck laat een stapel vallen, een klant stuurt een pallet van 48 kg terug die over de datum is, de thermostaat in de koeling klapt eruit en ze gooien 80 kg Boerenkaas weg) schieten zo over het plafond heen.

Waarschuwing

Als je je typesysteem niet kunt zien, kun je niet redeneren over de plafonds ervan. Hex in een CHAR is type erasure met extra stappen, en de volgende die het schema leest zal er veel meer in vertrouwen dan het aankan.

Waarom operations nooit klaagde

Dit is het deel van het verhaal dat ons het langst kostte om te snappen. De bug stond 28 jaar live. Niemand had ooit een ticket geopend. Hoe?

Drie redenen, op aflopende vrolijkheid:

  1. De originele PHP deed pack('s', $grams) zonder overflow-check. PHP, vóór strict types, wrapte een correctie van 50.000 gram vrolijk naar −15.536 gram. Het systeem accepteerde het.
  2. De applicatielaag erboven draaide een sanity-check: elke correctie waarvan het teken niet matchte met de redencode van de operator (een negatieve delta bij een BIJBOEKEN, bijvoorbeeld) werd teruggekaatst naar een papieren spreadsheet die de magazijnchef bijhield. Hij had die getallen in zijn hoofd en stemde ze maandelijks af tegen de CMR-vrachtbrieven van de zuivelleverancier.
  3. De magazijnchef ging in maart 2024 met pensioen. Niemand had het ons verteld.

Dus toen ons migratiescript — geschreven tegen het schema, niet tegen dertig jaar institutionele workaround — hexdec() draaide op de CHAR(8)-waarden en ze als SMALLINT in Directus opsloeg (want daar past 32.767 net in), reproduceerde het de stille afkapping trouw. Dag vijftien, op de werkvloer in het magazijn, was de eerste keer dat iemand het nieuwe ERP tegen een echte weegschaal op palletformaat hield.

De klap voor de traceabiliteit

Van de 41.000 correcties in de live tabel waren er 4.200 in stilte afgekapt. Ruwweg 10%. De meeste klein. Zesenveertig waren materieel — afboekingen op palletformaat van single-origin Boerenkaas waar de vastgelegde delta 15 tot 60 kg afweek van de echte delta.

Onder Verordening (EG) nr. 178/2002 — de Algemene Levensmiddelenwet van de EU — verplicht artikel 18 elke exploitant van een levensmiddelenbedrijf om elke partij één stap vooruit en één stap terug te kunnen traceren. Voor een groothandel betekent dat: gegeven een partijnummer moet je precies kunnen reconstrueren welke kazen het pand hebben verlaten, in welke staat, naar wie, met welk gewicht. Met 4.200 records waarop de vastgelegde voorraadcorrectie verkeerd stond, sloot het audittrail niet aan op de CMR-gewichten van de uitgaande zending. De NVWA stond binnen acht weken op de agenda voor een routine-inspectie.

Exploitanten van levensmiddelen- en diervoederbedrijven moeten kunnen nagaan wie hun een levensmiddel, diervoeder, voedselproducerend dier of stof die bestemd is om in een levensmiddel of diervoeder te worden verwerkt, of waarvan kan worden verwacht dat zij daarin wordt verwerkt, heeft geleverd.

Verordening (EG) nr. 178/2002, artikel 18

De delta's reconstrueren zonder geschiedenis te herschrijven

De fix was niet "pas de foute rijen aan". Audittrails worden slechter, niet beter, als je ze in stilte herschrijft. De fix was:

  1. Parse elke CHAR(8)-waarde opnieuw als signed 32-bits integer, niet als signed 16-bits. (Voor rijen die vóór de bug zijn weggeschreven, waren de bovenste 16 bits correct sign-extended; de encoder klopte, het mentale model van de applicatie erbij niet.)
  2. Vergelijk per rij de geparste delta tegen het eindvoorraadtotaal dat voor die dag in de zustertabel partij_voorraad_dag staat. Komen ze overeen, dan was de rij in orde.
  3. Komen ze niet overeen, dan was het eindvoorraadtotaal — weggeschreven door een aparte code path die de weegschaal direct uitleest — de waarheid. Bereken de gecorrigeerde delta als stock_after − stock_before.
  4. Schrijf de correctie als nieuwe rij in voorraad_correctie met redencode MIGRATIE_HERSTEL_2026, met verwijzing naar het id van de originele rij. De originele rij blijft staan. De geschiedenis blijft staan.

De reconstructie-query, nadat we onszelf ervan hadden overtuigd dat het schema maar een deel van de waarheid vertelde:

INSERT INTO voorraad_correctie_v2
  (partij_id, delta_g, reden, original_id, ts)
SELECT
  v.partij_id,
  (d.stock_after_g - d.stock_before_g) - parsed.delta_int AS delta_g,
  'MIGRATIE_HERSTEL_2026',
  v.id,
  NOW()
FROM voorraad_correctie v
JOIN partij_voorraad_dag d
  ON d.partij_id = v.partij_id
 AND d.dag = DATE(v.ts)
JOIN LATERAL (
  SELECT CAST(CONV(v.delta_g, 16, 10) AS SIGNED) AS delta_int
) parsed ON TRUE
WHERE ABS(parsed.delta_int) BETWEEN 32700 AND 32767;

Die BETWEEN 32700 AND 32767-clausule is de geur. Echte gewichtsdata clustert niet tegen een numerieke grens aan, tenzij die grens iets met de data doet. Toen we een histogram van ABS(parsed.delta_int) over de hele tabel draaiden, was de verdeling log-normaal tot ongeveer 30.000 gram en stond er daarna een verticale piek op precies 32.767. Die piek wás de bug, zichtbaar in twee minuten SELECT en een staafdiagram. We hadden hem op dag één moeten plotten.

De audit die we op dag nul hadden moeten draaien

Een schema is een claim. Een migratiescript dat die claim vertrouwt en de data niet profileert, is een script dat elke leugen die het schema ooit verteld heeft in stilte doorzet.

De pre-migratie audit die dit binnen tien minuten had gevangen:

-- For every numeric or numeric-encoded column,
-- bucket the values by power of two and look for a cliff.
SELECT
  FLOOR(LOG2(ABS(CAST(CONV(delta_g, 16, 10) AS SIGNED)) + 1)) AS bit_bucket,
  COUNT(*) AS n
FROM voorraad_correctie
WHERE delta_g <> '00000000'
GROUP BY bit_bucket
ORDER BY bit_bucket;

Heeft het histogram een klif bij bucket 15 (215 = 32.768), dan heb je een 16-bits gecodeerde kolom. Een klif bij bucket 31, een 32-bits gecodeerde kolom. Hoe dan ook: die klif vertelt je wat de data dénkt te zijn, ongeacht wat het kolomtype zegt dat het is.

Kernpunt

Voordat je een verouderde database migreert, plot je een histogram van elke numerieke of numeriek-gecodeerde kolom. Een klif op een macht-van-twee-grens benoemt de bug voor je, zonder dat je in de broncode hoeft te kijken.

Wat we op dag nul anders zouden doen

Drie aanpassingen, in de volgorde waarin we ze nu bij elke migratie toepassen:

  • Praat met de magazijnchef vóór het schema. De gepensioneerde magazijnchef had ons binnen twintig minuten over de parallelle spreadsheet verteld. Institutionele workarounds zijn de negatieve ruimte van elke legacy-bug.
  • Profileer elke kolom die op een getal lijkt, zelfs als het een string is. CHAR, VARCHAR, zelfs ENUMs verbergen soms integers. Histogram ze. Zoek naar kliffen.
  • Schrijf het rollback-pad vóór het forward-pad. Reparaties van een audittrail moeten additief zijn — nieuwe rijen, geen herschrijvingen — en de rollback moet de gecorrigeerde geschiedenis bewaren.

Toen we het Directus + Nuxt-portaal voor deze Goudse kaasgroothandel bouwden, liepen we tegen een hex-codering uit 1998 aan die het schema niet kon zien. We losten het op door de oude database te behandelen als bewijsmateriaal in plaats van als single source of truth — een aanpak voor legacy-migratie waarbij we eerst profileren, dan migreren, en het audittrail nooit ter plekke aanpassen.

Heb je een PHP 5.x- of 7.x-portaal dat je wilt verhuizen, dan is het kleinste wat je vandaag kunt doen één query per quasi-numerieke kolom: een histogram gebucketed per macht van twee. Tien minuten. Het vertelt je welke kolommen liegen.

Kern

Voordat je een verouderde database migreert, maak van elke numerieke kolom een histogram per macht van twee. Een klif bij bucket 15 of 31 benoemt de type-breedte-bug voor je.

FAQ

Waarom bleef de bug 28 jaar onopgemerkt?

De originele PHP wrapte waarden boven het bereik in stilte, en de magazijnchef hield een papieren spreadsheet bij voor correcties op palletformaat. Hij ging in maart 2024 met pensioen en de workaround vertrok met hem.

Waarom de foute rijen niet gewoon ter plekke aanpassen?

Voedseltraceabiliteitswetgeving behandelt de originele registratie als bewijs. We voegden correctierijen toe die naar de originelen verwijzen, zodat het audittrail intact blijft onder artikel 18 van EU 178/2002.

Hoe vind je type-breedte-bugs vóór een migratie?

Maak een histogram van elke numerieke of numeriek-gecodeerde kolom, gebucketed per macht van twee. Een verticale piek bij bucket 15 of 31 betekent dat een 16- of 32-bits plafond de data in stilte afknijpt.

Gaat Directus standaard goed om met grote integerkolommen?

Directus respecteert het type dat je in de onderliggende database opgeeft. Voor gewichtsdelta's boven een kilo gebruik je INT of BIGINT — nooit SMALLINT, en nooit een string-gecodeerde integer.

phpmysqllegacy sitesmigrationarchitecturecase study

Iets bouwen?

Start een project