030 —PHP
PHP 5.6 naar 8.2 in één weekend: de realistische versie
De Loom kwam binnen om 23:41 op vrijdagavond. 48 uur om een 14 jaar oude PHP-codebase van 5.6 naar 8.2 te krijgen. Dit is de volgorde waarin het brak.
De Loom kwam binnen om 23:41 op een vrijdag. Een Nederlands bureau waar we mee werken had een 14 jaar oude PHP-applicatie geërfd van een logistieke klant, en hun managed host had net de standaardmail verstuurd: "PHP 5.6 wordt maandag om 09:00 CET uitgeschakeld." Het bureau had twee werkdagen om de codebase naar PHP 8.2 te brengen, feature-pariteit te herstellen, en geen paniek te factureren. De sprong van 5.6 naar 8.2 slaat vier major versies over. De meeste blogposts over die sprong doen alsof je het kunt plannen. Echte geforceerde migraties geven je die luxe zelden.
Dit is een case study van wat er dat weekend werkelijk gebeurde. Namen zijn geanonimiseerd. De codepatronen zijn typerend voor elke langlopende custom PHP-applicatie die we de afgelopen vijf jaar geopend hebben.
Hoe de codebase eruitzag om 23:42
De applicatie was een custom vrachtboekingsportaal op één VPS. Apache 2.2, PHP 5.6.40, MySQL 5.5, en 71.000 regels PHP verdeeld over drie losjes gekoppelde modules. De helft was geschreven in 2012. De andere helft was een poging uit 2019 om delen ervan te moderniseren, halverwege gestaakt. Geen tests. Eén cron-bestand. Een config.inc.php met de database-credentials in platte tekst.
Het eerste wat we deden was uitschrijven wat de cutover categorisch niet zou overleven. De backward incompatible changes-pagina's in de PHP-handleiding zijn hét beste document hiervoor. We liepen ze op volgorde door: 5.6 naar 7.0, 7.0 naar 7.4, 7.4 naar 8.0, 8.0 naar 8.2. Stuk voor stuk kort. Lees ze op volgorde en je hebt een werkbare mentale kaart van elk breaking pattern tussen jou en maandagochtend.
De greps die het meest opleverden:
grep -rEn "mysql_(query|connect|fetch_|num_rows|real_escape)" .
grep -rEn "ereg_?|split\(|each\(|create_function" .
grep -rEn '\$[A-Za-z_]+\{[0-9]+\}' .
grep -rEn "function +[A-Z][A-Za-z_]*\s*\(" . # PHP4-style constructors (heuristic)
Dat leverde 318 hits op verspreid over 47 bestanden. Ongeveer 80% in twee bestanden. Dat was de bemoedigende ontdekking van de nacht. Legacy PHP verdeelt zijn zonden zelden gelijkmatig. Twee bestanden bezitten het grootste deel van de technical debt, en die vind je meestal met één weekend grep-werk.
Zaterdag 02:00. Het plan dat we op een servet schreven
We zouden niets gaan refactoren. We zouden de kleinst mogelijke wijziging toepassen om elk patroon op de doelruntime te laten draaien, de resulterende warnings op zondag oppakken, en uitleveren. De lijst:
- Vervang alle
mysql_*-aanroepen door een dunnemysqli-shim. Dezelfde call sites, alleen functienamen met prefix. - Zet PHP4-stijl constructors om naar
__construct. Mechanisch werk. - Vervang
eregensplitdoor hunpreg_-equivalenten. - Fix curly-brace string offsets (
$s{0}wordt$s[0]). - Draai de applicatie lokaal op PHP 8.2 met
error_reporting(E_ALL)en kijk wat er in vlammen opgaat.
We hebben het database-schema niet aangeraakt. We hebben de templates niet aangeraakt. We hebben helemaal niets opgeruimd. Elke wijziging die niet nodig was om de boel te laten booten, werd doorgeschoven naar een vervolgsprint. De grootste fout die mensen maken bij een geforceerde PHP-migratie, is hem behandelen als het moment om eindelijk de code op te knappen. Dat is het niet. Het is het moment om de code te laten draaien.
De volledige shim:
function db_query($sql) {
global $db;
$r = mysqli_query($db, $sql);
if ($r === false) {
error_log("SQL: " . mysqli_error($db) . " :: " . $sql);
}
return $r;
}
function db_fetch_assoc($r) { return $r ? mysqli_fetch_assoc($r) : false; }
function db_num_rows($r) { return $r ? mysqli_num_rows($r) : 0; }
function db_escape($s) {
global $db;
return mysqli_real_escape_string($db, $s);
}
Daarna een sed-pass over elk PHP-bestand:
find . -name "*.php" -print0 | xargs -0 sed -i '' \
-e 's/mysql_query(/db_query(/g' \
-e 's/mysql_fetch_assoc(/db_fetch_assoc(/g' \
-e 's/mysql_num_rows(/db_num_rows(/g' \
-e 's/mysql_real_escape_string(/db_escape(/g'
Het idee van de shim is dat we nog niet proberen SQL-injectie te fixen of te refactoren naar prepared statements. Dat is een Q3-project. De shim koopt de applicatie een runtime op de nieuwe interpreter zonder de call sites te veranderen.
Zaterdag 11:00. Wat er daadwerkelijk brak op PHP 8.2
Tegen het einde van zaterdagochtend bootte de applicatie. Ongeveer de helft retourneerde een 500. De fouten vielen uiteen in vijf categorieën.
Dynamic property-deprecations
PHP 8.2 markeerde het toekennen aan niet-gedeclareerde properties als deprecated (zie de RFC). De legacy code deed dingen als $user->cached_orders = [...] van buiten de klasse, op een klasse die cached_orders nooit had gedeclareerd. In productie had dat een stille logregel moeten zijn, maar de host had display_errors=On staan in de gedeelde php.ini, dus de deprecation-banner verscheen midden in de JSON die de iOS-app ophaalde, en de iOS-app stopte met het parsen van responses.
Twee opties: elke property netjes declareren, of #[\AllowDynamicProperties] toevoegen aan de klassen die het probleem veroorzaakten. We deden het tweede. Het is de gedocumenteerde escape hatch en het kostte veertien minuten.
String-interpolatie met curly braces
"Hello ${name}" is deprecated. "Hello {$name}" mag wel. De fix is een regex-pass en een code review.
grep -rEn '\$\{[A-Za-z_]' --include="*.php" .
utf8_encode en utf8_decode
Beide deprecated in 8.2. De applicatie gebruikte utf8_encode op kolomwaarden die uit een latin1 MySQL-tabel kwamen (charmant). Vervanging: mb_convert_encoding($s, 'UTF-8', 'ISO-8859-1'). Zelfde resultaat, geen deprecation, vereist mbstring, dat de meeste actuele hosting-builds standaard meeleveren.
Impliciete float-naar-int conversies
Een rapportagemodule deed $pages = $total / $per_page en gebruikte $pages vervolgens als array-index. PHP 8.1 maakte de impliciete float-naar-int conversie tot een deprecation warning. We wikkelden de toekenning in (int) ceil(...). Drie plekken.
Constructor signature mismatches
Subklassen die een parent constructor overschrijven met een andere signature gooien nu een error. We liepen er één keer tegenaan, in een payment-gateway-abstractie. De fix was ...$args aan de parent signature toevoegen en de suite opnieuw draaien. We hadden geen suite, dus we doorliepen de boeking-flow handmatig opnieuw.
Zaterdag 17:00. De cronjobs waar niemand op had ingelogd
Twee cronjobs draaiden 's nachts: één genereerde facturen, één synchroniseerde een voorraadfeed naar een partner via SFTP. Beide waren geschreven tegen PHP 5.6 CLI. Op de nieuwe CLI viel de factuurjob om, omdat hij create_function aanriep, verwijderd in 8.0. Twee regels, vervangen door een anonymous function. De voorraadfeed-job viel om omdat de ssh2_*-functies niet meegecompileerd waren in de PHP-build van de host. We schakelden over naar phpseclib, dat pure-PHP is en niets van de host vraagt. De composer install duurde twee minuten. De job draaide schoon bij de volgende tick.
De les, als die er is: als je grept op breaking patterns, grep dan ook de cron-bestanden. Die wonen meestal in /etc/cron.d/ of in een aparte map onder de application root, en worden standaard over het hoofd gezien.
Zondag 03:00. De .htaccess-wijziging waar niemand voor had gewaarschuwd
De nieuwe stack van de host draaide onder PHP-FPM in plaats van mod_php. De deploy van het bureau was geschreven in de veronderstelling van mod_php. Twee dingen braken direct.
Ten eerste zette de applicatie php_value upload_max_filesize 64M in .htaccess. Onder FPM kan Apache PHP-ini-waarden niet op die manier zetten. De Apache-foutmelding is glashelder zodra je weet waar je naar moet zoeken:
Invalid command 'php_value', perhaps misspelled or defined by a module not included in the server configuration
De vervanging staat in een .user.ini-bestand in de document root. De directives blijven hetzelfde, het bestandsformaat verandert:
upload_max_filesize = 64M
post_max_size = 80M
memory_limit = 256M
De php.net-docs beschrijven de syntax. De gotcha die het opschrijven waard is: .user.ini wordt standaard 300 seconden gecached. Test je wijzigingen en lijken ze niet door te komen, dan is dat waarom. Zet user_ini.cache_ttl = 0 in de php.ini van het testdomein.
Ten tweede brak het RewriteRule-blok dat het PHP-session-id in URL's plaatste voor clients met cookies uit (jazeker, in 2026), omdat de FPM-frontend PHPSESSID uit de querystring stripte. We hebben de URL-session-fallback volledig verwijderd. Geen enkele live client had cookies uitgeschakeld. We hebben de access log gecontroleerd voordat we het weghaalden.
Zondag 14:00. De rollback die we niet nodig hadden
Op zondagmiddag bootte de applicatie, parste de iOS-client de responses, draaiden beide cronjobs schoon, en werden PDF-facturen gegenereerd. We hadden aan het begin een rollback-plan opgesteld: één git checkout plus een mysqldump restore. We hebben het nooit gebruikt. Het dichtst kwamen we erbij toen we één composer install terugdraaiden omdat PHPMailer 6.10 ondersteuning had laten vallen voor een embedded font dat de factuurtemplate gebruikte. Terug naar 6.9.3 kostte 90 seconden.
De twee dingen die ervoor zorgden dat we de rollback niet nodig hadden:
- De shim. Door tijdens de migratie de SQL-toegang niet te refactoren, bleef het oppervlak van de wijziging klein genoeg om de bugs die wel verschenen ook klein te houden.
- De database met rust gelaten. De nieuwe interpreter praat probleemloos met MySQL 5.5. De database-upgrade landde zes weken later, los, met zijn eigen rollback-plan en zijn eigen weekend.
Wat vier weken anders had gemaakt
Eerlijk gezegd structureel weinig. De volgorde van een PHP 8.2-migratie blijft hetzelfde: eerst PHP, dan database, dan dependencies, daarna refactor. Wat vier weken je oplevert, is staging-pariteit en een echt testplan. Met 48 uur vervang je tests door een tail op de productie-errorlog en een betaalde stagiair die op zaterdagochtend door de booking-flow klikt. Allebei werkt het; eentje is rustiger.
Het andere wat we gedaan zouden hebben, is de .htaccess en .user.ini vanaf het begin onder versiebeheer brengen. Het pijnlijkste moment van het weekend was zondag om 04:11, toen er voor debugging een directive in de live .htaccess was aangepast en niemand zich de originele waarde nog herinnerde. Een back-up van vrijdagavond 23:50 bespaarde ons dertig minuten gokwerk.
Het kleinste wat hier hardop gezegd moet worden
Toen we Pier bouwden, kwamen we precies dit patroon keer op keer tegen. Het patroon: een bureau neemt een legacy site over, krijgt een host-EOL-mail, en besteedt een weekend aan greppen op mysql_ en het bewerken van .htaccess via FTP. Hoe we het uiteindelijk hebben opgelost: elke save maakt automatisch een snapshot, zodat de vraag "wat was de originele waarde" een antwoord van één klik krijgt in de version history, en de database-kant van de cutover leeft binnen dezelfde MySQL editor als de bestand-kant.
Heb je nog een PHP 5.6- of 7.x-site in productie staan, dan is het kleinste wat je vandaag kunt doen de vier grep-commando's bovenaan dit artikel tegen de codebase aanzetten. De output voorspelt je weekend.
— Vragen —
Kun je echt in één weekend van PHP 5.6 naar PHP 8.2?
Soms. Het hangt ervan af hoeveel van de breaking patterns zich concentreren in hoe weinig bestanden. De greps bovenaan dit artikel vertellen je binnen tien minuten of je codebase realistisch is voor een weekend-cutover.
Moet ik eerst naar PHP 7.4 als tussenstap?
Alleen als je weken over hebt. PHP 7.4 wordt al niet meer ondersteund en de meeste hostingmaatschappijen stoppen met het aanbieden ervan. Direct naar 8.2 betekent één ronde aan breuk in plaats van twee, en één weekend werk in plaats van twee.
En de database tijdens een PHP-versiesprong?
Laat hem staan. PHP 8.2 praat zonder klagen met MySQL 5.5, 5.6, 5.7, 8.0 en MariaDB 10.x. Een database-upgrade koppelen aan een PHP-upgrade verdubbelt het oppervlak van de wijziging zonder de waarde te verdubbelen.