PHP
Een PHP 5.6 ERP overnemen: 180 stored procs en volle disk
Op een vrijdag in april kon de productieplanner niet inloggen in de ERP. Maandag de werkplaats ook niet. Het backupscript was al tien weken stil.

De metaalwerkplaats draait twee shifts. De eerste zaagt en last framesecties vanaf 07:00. De tweede pakt en stageert vanaf 15:00. Op een vrijdag in april kwam de planner om 06:45 binnen, opende de ERP, en kreeg een wit scherm. Tegen de tijd dat de lassers hun eerste koffie hadden, kon ook niemand op kantoor meer inloggen. Wij zaten om 10 uur aan de lijn.
Eenentwintig mensen, één ERP, op maat gebouwd in 2011 door een freelancer die drie jaar later naar Adelaide verhuisde. Niemand bij het bedrijf had een contract met hem. Niemand had ooit een schemadiagram gezien. Er was geen schemadiagram. De twee directeuren hadden een Dropbox-map met drie pdf's, allemaal getiteld Final_handover_v2, en een 7-Zip-archief van de codebase uit 2015 waarvan niemand het wachtwoord nog wist.
De inlogpagina gaf een wit scherm omdat de session-tabel was gegroeid tot 4,2 miljoen rijen en de server halverwege een INSERT zonder geheugen kwam te zitten. Dat was het oppervlaktesymptoom. Het echte probleem was ouder en stiller, en het kostte ons bijna een week om het in kaart te brengen.
De stack die we erfden
De applicatie draaide op PHP 5.6.40 op Debian 8 (jessie). Beide zijn al jaren end-of-life: PHP 5.6 bereikte end-of-life op 31 december 2018, en Debian 8 LTS stopte in juni 2020. De server was alleen bereikbaar op poort 80 omdat iemand in 2019 UFW had uitgezet tijdens het troubleshooten van een printer.
De codebase was 84.000 regels PHP verdeeld over 612 bestanden. Geen autoloader, geen namespaces, geen dependency manager. Composer was niet geïnstalleerd. Drie include-bestanden (db.php, session.php, helpers.php) definieerden samen 240 globale functies. MVC was niet zozeer afwezig als wel actief geweigerd.
Maar de businesslogica zat niet in de PHP. De businesslogica zat in de database.
Honderdtachtig stored procedures
MySQL 5.6 draaide op dezelfde server. Het schema had 94 tabellen en 180 stored procedures. We trokken ze eruit met de voor de hand liggende query:
SELECT ROUTINE_NAME, ROUTINE_TYPE, CREATED, LAST_ALTERED
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = 'erp'
ORDER BY LAST_ALTERED DESC;
De nieuwste procedure was in 2019 gewijzigd. De oudste dateerde uit 2012. Ongeveer veertig hadden de prefix sp_calc_ en deden prijswiskunde tegen een kostentabel die zelf gevuld werd door een andere procedure die las uit een CSV die elke nacht werd geïmporteerd door wéér een andere procedure. De dependency graph, toen we 'm eenmaal tekenden, leek op een bord capellini.
Procedures riepen procedures aan. Eén ervan, sp_order_close_v3, was 1.400 regels MySQL met een IF/ELSE-ladder van 41 stappen en drie geneste cursors. Hij verzorgde orderafsluiting, factuurgeneratie, voorraadafboeking, commissieverdeling, en een mailtrigger die afging via UDF_SENDMAIL, een user-defined function gecompileerd tegen MySQL 5.6 waarvan niemand de broncode had.
We probeerden niet om in de eerste week alle 180 te lezen. We rangschikten ze op hoe vaak ze werden aangeroepen. De MySQL general log stond (waarschijnlijk per ongeluk) al drie jaar aan en het bestand was geroteerd, maar de laatste 60 dagen stonden nog op disk. Tien procedures waren goed voor 92% van alle calls. Die tien werden eerst gelezen. De andere 170 kregen een one-line comment header (UNREAD, last altered YYYY-MM-DD) en gingen op een backlog. In een systeem dat zo oud is, zijn de stored procedures vaak wel de applicatie; de call-frequency log is de juiste plek om te beginnen met lezen.
Een session-tabel die deed alsof hij een session store was
De standaard session handler van PHP schrijft naar /tmp. Deze niet. session.save_handler stond op user en wees naar een class SessionDb die schreef naar een tabel sess_users met deze vorm:
CREATE TABLE sess_users (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(128),
user_id INT,
payload TEXT,
created DATETIME,
last_seen DATETIME
) ENGINE=InnoDB;
Geen index op session_id. Geen TTL. Geen cleanup job. Elke page load deed een full scan over 4,2 miljoen rijen om één session-rij te vinden, en schreef daarna een nieuwe rij. Login was het traagste endpoint in het systeem, omdat login twee keer schreef.
De eerste fix was een migratie van vier regels:
ALTER TABLE sess_users
ADD UNIQUE INDEX idx_session_id (session_id),
ADD INDEX idx_last_seen (last_seen);
DELETE FROM sess_users WHERE last_seen < NOW() - INTERVAL 30 DAY;
Dat verwijderde 4,18 miljoen rijen en bracht de gemiddelde inlogtijd van 9,2 seconden terug naar 140ms. We voegden een nachtelijke cleanup-procedure toe aan de cron en lieten het schema verder met rust. De échte fix, sessies verplaatsen naar Redis of naar een signed cookie, staat op de lijst voor week vier, niet voor week één. OWASP's Session Management Cheat Sheet is de juiste referentie als je daar bent; wij gaan 'm gebruiken.
De backup die naar een gesloten deur schreef
De cron had één backup job. Hij draaide elke nacht om 02:30. Hij zag er zo uit:
#!/bin/bash
DATE=$(date +%Y%m%d)
mysqldump -u root -p'...' erp > /var/backup/erp-$DATE.sql
tar -czf /var/backup/files-$DATE.tar.gz /var/www/erp
find /var/backup -mtime +30 -delete
Vier problemen, in volgorde van wreedheid.
Ten eerste, geen set -e. Het mysqldump-commando faalde al sinds maart omdat de disk waar /var/backup op stond vol was. Bash maakte dat niet uit. De volgende regel werd uitgevoerd. De regel daarna ook. De cron mailde niks omdat cron-mail in 2017 was uitgezet toen de SMTP relay veranderde.
Ten tweede, find -mtime +30 -delete werkt prima, maar het wiste van dezelfde disk die vol zat, omdat oude, half geschreven backups uit 2024 nog steeds als bestand telden. De cleanup kon niet eens afronden.
Ten derde, tar -czf eindigde elke nacht met exit code 1 omdat een aantal bestanden in /var/www/erp eigendom waren van www-data en de cron als root draaide over een NFS-mount die op een gegeven moment read-only was teruggemount. Exit code 1 werd nergens gelogd.
Ten vierde, het databasewachtwoord stond in plain text in een world-readable cron-script. Daar gaan we niet lang bij stilstaan; dat plaatje kun je zelf wel maken.
De fix was, opnieuw, het kleinste redelijke ding. Een nieuwe disk gemount op /srv/backup. Een nieuw script met set -euo pipefail, exit codes gecheckt, output naar een logfile dat roteert, en aan het eind een healthcheck ping naar healthchecks.io. Als de ping er om 03:30 niet is, krijgt iemand een sms. We gebruiken dit patroon bij alle legacy-overnames omdat de kosten van een failure te hoog zijn om over te laten aan ik check de logs wel een keer.
Een backup-script dat niemand actief opbelt als het faalt, is geen backup. Het is een verhaaltje dat je jezelf vertelt over een backup.
Triage in week één
Aan het eind van de eerste week hadden we nog niks van PHP 5.6 afgemigreerd, geen stored procedures gerefactord, en geen nieuwe infrastructuur geïntroduceerd behalve de backup-disk. Wat we wél hadden gedaan:
- De session-tabel van een index voorzien en opgeschoond.
- Een fatsoenlijke backup opgezet met een off-box kopie naar S3 en een geverifieerde restore (we hebben hersteld naar een staging-server en zijn ingelogd als een echte gebruiker).
- De productieserver achter Cloudflare gezet zodat we konden rate-limiten en zo nodig de wereld konden afsluiten.
- MySQL slow-query logging aangezet op 500ms en gericht op een logfile dat we daadwerkelijk lezen.
- De 180 stored procedures geïnventariseerd met last-altered-datums en call frequency, en de top tien op een leeswachtrij gezet.
De ERP bleef in de lucht. De productie heeft daarna niet meer stilgestaan. Dat was de oplevering.
Migreren boven herschrijven
De verleiding bij zo'n systeem is om het failliet te verklaren en opnieuw te beginnen. Soms is dat de juiste keuze. Meestal niet, want de businesslogica in die 180 procedures codeert vijftien jaar aan edge cases die niemand zich herinnert en die niemand opnieuw zal ontdekken in een kickoff-workshop. De commissie wordt 60/40 gesplitst, behalve als de order naar België gaat, dan is het 70/30, behalve als de klant op het legacy contract uit 2014 zit. Die regel zit in een stored procedure. Hij zit in niemands hoofd.
Wat we voor maand twee tot en met zes voorstelden, was bescheiden. De database loshalen van de applicatieserver en op een managed MySQL 8 zetten, met de stored procedures intact. De semantiek van stored procedures in MySQL 8 ligt dicht genoeg bij 5.6 dat de meeste zonder wijzigingen porten; degene die dat niet doen breken meestal op strengere SQL modes (ONLY_FULL_GROUP_BY, NO_ZERO_DATE) en komen direct boven water op een staging-kopie.
De PHP 5.6 applicatie inpakken in een dunne PHP 8.3 facade die nieuwe endpoints proxiet terwijl oude endpoints nog op de legacy code uitkomen. Strangler-fig, geen big-bang. Elke nieuwe feature ship je op de moderne stack. Elk oud scherm blijft leven tot het herschreven of opgeruimd wordt.
Eerst de top tien procedures lezen en documenteren, daarna de volgende twintig, en de rest triageren. We hebben een agent draaien die procedure-broncode doorploegt en geannoteerde samenvattingen oplevert met input/output schemas en gevonden call sites; dat is sneller dan met de hand lezen en de diffs zijn makkelijk te reviewen. Het model is zelden het interessante deel van dat werk. Wat telt is de loop eromheen: wat je erin stopt, in welk schema je de output dwingt, en hoe je verifieert wat eruit komt voordat het de documentatie raakt.
Een audit van vijf minuten
Drie checks kosten je samen minder dan een uur en leveren meer op dan welke architecture review dan ook. Draai php -v op de server: begint hij met 5 of 7.0 tot 7.3, dan draai je niet meer ondersteunde software met bekende CVE's. Lees je backup-script en verifieer dat de laatste succesvolle restore binnen de laatste 90 dagen viel, en niet de laatste 900. Query information_schema.ROUTINES en tel wat erin staat; als dat aantal je verrast, zit je applicatielogica deels in de database, en elk migratieplan dat doet alsof dat niet zo is, mist een half jaar.
Toen wij de Tilburgse ERP overnamen, was de winst van de eerste week niet 'm vervangen. Het was het bedrijf achttien maanden stabiliteit kopen om een echte legacy migratie te kunnen plannen in plaats van een noodgreep.
Open vanavond je eigen backup-script en grep op set -e. Staat het er niet in, dan is de war story al geschreven.
Kern
In een legacy PHP-systeem zit de businesslogica meestal in de database. Lees de stored procedures op call frequency, niet alfabetisch.
FAQ
Is PHP 5.6 nog veilig om te draaien op een gehardende server?
Nee. PHP 5.6 krijgt sinds eind 2018 geen security patches meer. CVE's in PHP, de core-extensies of de meegeleverde OpenSSL build worden niet teruggepoort. Netwerk-hardening lost dat niet op.
Moeten we een legacy PHP ERP herschrijven of migreren?
Migreer eerst, herschrijf per surface area. Businessregels in stored procedures vangen jaren aan edge cases. Een strangler-fig facade laat de omzet doorlopen terwijl je scherm voor scherm vervangt.
Hoe lees je 180 stored procedures zonder er een maand aan kwijt te zijn?
Sorteer ze op call frequency in de slow-query of general log. Meestal nemen tien stuks het grootste deel van het verkeer voor hun rekening. Lees die eerst, documenteer inputs en outputs, en zet de rest op een backlog.
Wat is de goedkoopste zinvolle upgrade voor een verouderd backup-script?
Zet set -euo pipefail erin, check exit codes, schrijf naar off-box opslag, en ping een heartbeat-dienst die een mens belt als de ping niet op tijd binnenkomt.