← Blog

PHP

Legacy PHP redden: anatomie van een €1,8M boekingsplatform

Een custom PHP 5.6 boekingsplatform, €1,8M per jaar, drie ongedocumenteerde payment providers en een session-tabel die 40GB per maand groeide. De overdracht duurde veertig minuten.

Jacob Molkenboer· Oprichter · A Brand New Company· 6 jun 2026· 10 min
Leren grootboek met messing hoeken, messing sleutel op crème kaart met groen lint, gezegelde envelop, ijzeren label.

De overdracht duurde veertig minuten. De vorige developer had het platform in 2014 gebouwd, hield het negen jaar lang in zijn eentje draaiend en woonde nu drie tijdzones verderop. Zijn antwoord op de meeste vragen was een lange stilte, gevolgd door een variant op "Ik denk dat dat elke nacht draait, maar ik weet niet meer waarom."

Het platform draaide jaarlijks €1,8M aan boekingen voor een Nederlandse touroperator. Custom PHP, custom MVC, custom sessionafhandeling, alles custom. Geen tests. Geen CI. Eén staging server die sinds 2019 niet meer was bijgewerkt. Het contract met de vorige developer eindigde op een vrijdag. Het hoogseizoen begon over vier weken.

Dit is het verhaal van wat we aantroffen, wat we als eerste aanpakten en wat we lieten staan. Zit je op een vergelijkbare codebase, dan is de volgorde belangrijker dan de tooling.

Het eerste wat we openden

We openden niet de applicatiecode. We openden de database.

De sessions-tabel was 40GB en groeide met zo'n 1,3GB per dag. De standaard session garbage collector van PHP was jaren geleden in php.ini uitgezet, vermoedelijk om te voorkomen dat hij de tabel zou locken tijdens een piekmoment. Niemand had er een vervanger voor geschreven.

We draaiden een count.

SELECT COUNT(*) AS expired_rows
FROM sessions
WHERE last_activity < UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY);

Het antwoord was 47 miljoen rijen. Het boekingsplatform verwerkte ongeveer 3.000 actieve sessions per dag. De overige 46.999.000 rijen waren spoken van afgebroken winkelwagens uit de lockdowns van 2020, nooit opgeruimd.

De fix was een cron van vijf regels. De moeilijker vraag was waarom niemand het had gemerkt. Het antwoord, zoals bij de meeste legacy-vragen, was dat iemand het ooit wél had opgemerkt, in 2020 een Jira-ticket had geschreven en het bedrijf had verlaten voordat hij eraan toe kwam.

Let op

Voordat je iets verwijdert uit een session-tabel op een live boekingsplatform: maak een mysqldump en test de restore. Oude PHP-code serialiseert soms de helft van de auth-state in de session blob. Een onzorgvuldige DELETE kan elke actieve klant midden in de checkout uitloggen. Wij voerden de cleanup in stappen van DELETE ... LIMIT 50000 uit om 03:00 en hielden voor elke batch de binlog in de gaten.

Drie payment providers, drie spijtgevallen

Het boekingsplatform sprak met drie payment providers. Geen van de integraties was gedocumenteerd. Geen van de flows leek op de andere.

  • De eerste was iDEAL via een Nederlandse PSP die sinds de bouw twee keer was gerebrand. De merknaam in de code was die uit 2015.
  • De tweede was een directe creditcard-integratie tegen de inmiddels verouderde REST v1 API van een provider. Die provider mailde al twee jaar naar het oude adres van de oprichter met het verzoek te migreren.
  • De derde was PayPal Classic, waar PayPal sinds 2017 vriendelijk doch dringend om je vertrek vraagt.

We vonden de live credentials op drie verschillende plekken: een .env-bestand, een config.inc.php en als hardcoded string in lib/payment/cc.class.php. De hardcoded key was een productie-key. Hij stond in de git history. De repo stond op een gedeelde GitLab waar nog vier ex-medewerkers toegang hadden.

We hebben die week alles geroteerd.

Daarna telden we het verkeer. We trokken twaalf maanden aan access logs, grepten op de drie payment callback URLs en maakten een transactietelling per provider.

zcat access.log.*.gz \
  | awk '$7 ~ /\/payments\/(provider_a|provider_b|paypal)\/callback/ {print $7}' \
  | sort | uniq -c | sort -rn

Eén provider deed 94% van het volume. Eén deed 5%. Eén faalde stil sinds een TLS-wijziging in 2022 en had in vijftien maanden nul succesvolle betalingen verwerkt. Niemand had het gemeld, want de fallback-logica van het platform routeerde fouten geruisloos naar een andere provider, en de finance-afdeling reconcilieerde op totaal, niet op bron.

De saaie les: voordat je iets migreert, tel wat er daadwerkelijk draait. De keuze van de vendor is minder belangrijk dan de zorgvuldigheid.

Voor de meeste teams is de vraag niet Adyen versus Stripe versus Mollie. De vraag is of je de providers in productie uit je hoofd kunt opnoemen, en of dat antwoord matcht met de logs. Voor onze klant consolideerden we uiteindelijk op één PSP die iDEAL, Bancontact, kaarten en PayPal onder één contract dekte, zodat ze nooit meer een vierde integratie zouden ontdekken waar niemand het over had. De PSP die we kozen was degene waarbij webhook-signature-verificatie één function call in hun officiële SDK was, niet degene met de mooiste pricing pagina. De volledige payment-migratie kostte drie engineerdagen, geen drie weken, omdat we de dode provider al hadden verwijderd en de live provider hadden leren kennen voordat we ook maar één regel code schreven.

Het PHP 5.6-probleem

PHP 5.6 bereikte end of life op 31 december 2018. Er is al zeven en een half jaar geen security-patch meer uitgekomen. Je kunt dat nakijken op de officiële PHP supported versions pagina; 5.6 staat er niet op, en al heel lang niet.

Het boekingsplatform draaide er in 2026 nog op. Apache 2.2 met mod_php, prefork MPM, gedimensioneerd voor een tijdperk waarin servers minder RAM hadden dan je laptop. Elke Composer-dependency in het project was ouder dan de EOL-datum. Twee daarvan hadden ongepatchte CVE's die ouder waren dan de jongste engineer in ons team.

We haalden de codebase door Rector met de rule sets voor PHP 7.0, 7.4 en 8.0, één set tegelijk, in een branch die we de eerste drie weken niet mergeden. Rector handelde ongeveer 60% van de syntax-migratie zelf af: short array syntax, scalar type hints, null coalescing, het vervangen van mysql_* calls door PDO.

De 40% die Rector niet kon, dáár zat het echte oorlogsverhaal.

Wat Rector niet kan oplossen

Drie soorten code gedragen zich stilletjes anders tussen PHP 5.6 en 8.2. Geen van drieën gooit een fout bij het parsen. Alle drie gooien ze er één om 03:00 op zaterdag.

  • SQL opgebouwd door $_REQUEST-waarden in strings te concatenaten. PHP 5.6 was de laatste versie waarin je dit kon shippen en het meestal nog overleefde. PHP 8 draait het nog steeds. Je moet het nog steeds niet toelaten.
  • Code die leunt op de stille type juggling van PHP 5. Er was een functie die boeking-IDs als string vergeleek tegen integers met ==. Op PHP 8 zijn de vergelijkingsregels aangescherpt, waardoor 0 == "foo" eindelijk false is. Goed voor de logica, slecht voor een autorisatiecheck die toevallig op het oude gedrag steunde.
  • Een zelfgebouwde autoloader die eval() aanriep op een gegenereerd PHP-bestand met alle class paths. We vervingen hem door PSR-4 en een gegenereerde composer.json-entry.

De Rector-branch werd onze schaduwcodebase. We draaiden de bestaande test suite ertegen (één PHPUnit-bestand, veertien tests, vier daarvan zonder commentaar geskipt) en voegden tachtig tests toe naarmate we dingen vonden die stuk gingen. Tegen week drie had de schaduwbranch meer tests dan de voorgaande negen jaar aan development bij elkaar.

Het migratieplan dat we daadwerkelijk schreven

We migreerden niet naar Laravel. We geloven niet in het herschrijven van €1,8M aan werkende boekingsflow tijdens het hoogseizoen.

Het plan, op volgorde:

  1. De infrastructuur eerst. Van één verouderde VPS naar een klein Hetzner-cluster met managed MySQL 8 en Redis. Tijdens de verhuizing draaiden we de bestaande PHP 5.6-code in een container, puur om het OS los te koppelen van de applicatie.
  2. Sessions uit MySQL naar Redis. Alleen al hierdoor ging de database van constant in brand naar lauwwarm.
  3. PHP upgraden naar 8.2 met de Rector-opgeschoonde codebase, achter een feature flag op een parallel hostname. Beide versies twee weken naast elkaar draaien. Dagelijks error rates en boekingsconversie vergelijken.
  4. De drie payment providers consolideren achter één PSP, waarbij de oude endpoints als read-only routes bleven bestaan zodat support nog refundhistorie kon ophalen.
  5. Observability toevoegen. Het platform had er geen. We zetten Sentry erop, een Loki log pipeline en één Grafana dashboard met vier panelen: boekingen per uur, payment success rate, p95 response time, error rate.
  6. Dan, en pas dan, praten over CMS, framework en herschrijven. De meeste klanten komen nooit bij stap zes, want bij stap vijf is de pijn die ze deed bellen al weg.

Stap 1 tot 5 is waar bijna alle reddingswaarde zit. De rewrite betaalt zich meestal niet terug, en in het hoogseizoen is hij ronduit gevaarlijk. We hebben de afgelopen vier jaar zeven platformen op dit patroon gered; bij zes van de zeven besloot de klant na stap vijf dat hij de rewrite waar hij ons oorspronkelijk voor had ingehuurd, niet meer wilde.

Wat we in week één opleverden

Week één ging over sessions, secrets en observability. Niets flashy. Geen zichtbare verandering voor de klant.

We roteerden elk secret dat we konden vinden: de live payment key uit de hardcoded string, het database-wachtwoord (dat was de bedrijfsnaam plus 2014), de SSH-keys (root login stond nog open op poort 22), de SMTP-credentials, de Google Maps API-key die €180 per maand factureerde op een vier jaar oude kaart.

We installeerden acme.sh en haalden echte Let's Encrypt-certificaten binnen voor de vier subdomeinen die nog op self-signed certs uit 2019 draaiden. De boekingsbevestigingen liepen door een relay die ze sinds maart stilletjes weggooide; we ontdekten dat door Postfix-logs te lezen waar niemand naar had gekeken. Klanten hadden het hele jaar gebeld om te vragen waar hun bevestiging bleef, en support beantwoordde dat handmatig.

We zetten ook basale monitoring aan: een Uptime Kuma-instance die elke minuut het boekings-endpoint pingde, en één alert die naar de telefoon van de operations lead ging zodra de payment success rate onder de 90% kwam over een venster van vijftien minuten. De alert ging af op dag drie van week twee. Het bleek een rate-limit op de nieuwe PSP-sandbox, niet productie, maar de operations lead zei dat het de eerste keer in negen jaar was dat ze van een betalingsprobleem hoorde voordat een klant het meldde.

Aan het einde van week één was de kans op een datalek kleiner en de kans op een bezorgde e-mail groter. We hadden geen enkele feature toegevoegd. De klant was blijer dan in twee jaar.

Het kleinste dat vandaag al de moeite waard is

Zit je op een custom PHP-applicatie ouder dan drie jaar, dan kost het kleinste nuttige ding dat je vanmiddag kunt doen twee SQL-queries en een grep.

Open de database en vraag welke tabellen de disk opvreten:

SELECT table_name,
       ROUND((data_length + index_length) / 1024 / 1024 / 1024, 2) AS gb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 10;

Ga daarna naar de access logs en tel het verkeer per payment callback URL over de afgelopen 90 dagen. Vergelijk het resultaat met wat je finance-team elke maand reconcilieert. Als de getallen niet matchen, heb je een verhaal om uit te zoeken voordat iemand de codebase aanraakt. Weet je niet zeker welke logs het antwoord bevatten, dan is dat ook een bevinding: schrijf het op, want het is het eerste wat je migratiepartner gaat vragen.

Toen we de legacy-migratie voor de touroperator deden, was het probleem niet de PHP-versie of de framework-keuze. Het was dat niemand in het huidige team kon zeggen welke payment provider er daadwerkelijk live stond, en dat de stille fallback-logica dat twee jaar had verstopt. Onze legacy-trajecten beginnen tegenwoordig met die audit, op papier, voordat iemand een editor opent.

Kern

Voordat je een legacy PHP-systeem migreert, tel wat er daadwerkelijk draait. De meeste reddingen mislukken omdat het team de docs meer vertrouwt dan de access logs.

FAQ

Hoe lang duurt een migratie van PHP 5.6 naar PHP 8 meestal?

Voor een custom codebase van rond de 80k regels zonder tests: reken op acht tot twaalf weken focused werk. Rector pakt ongeveer 60% van de syntax. De resterende 40% bestaat uit stille gedragswijzigingen waar je eerst tests voor moet schrijven.

Moeten we de applicatie herschrijven of in plaats upgraden?

Upgraden in plaats. Een rewrite tijdens een lopende inkomstenstroom verliest bijna altijd geld in de eerste 18 maanden. Stabiliseer eerst de infrastructuur, ga over op de nieuwe PHP-versie, voeg observability toe, en bekijk dan pas of een rewrite nog de moeite waard is.

Kan Rector de hele PHP-versie-upgrade automatisch doen?

Nee. Rector handelt syntax en duidelijke API-wijzigingen af. Stille gedragsveranderingen, zoals de strengere string-naar-nummer vergelijkingsregels in PHP 8 of zelfgebouwde autoloaders met eval, kan hij niet vangen. Je hebt nog steeds tests nodig.

Waarom is een session-tabel van 40GB een probleem als MySQL het aankan?

MySQL kan het prima aan. Back-ups, replicatie, ALTER TABLE en disaster recovery niet. Een tabel van 40GB maakt elke infrastructuurwijziging trager en gevaarlijker, en de meeste rijen zijn afgebroken winkelwagens die niemand ooit nog gaat zien.

phpmysqllegacy sitesmigrationsecuritycase study

Iets bouwen?

Start een project