— Artikel — № 082

082 —PHP

Van PHP 7.4 naar 8.2: een compatibility pass in zes stappen

Een echte migratie van PHP 7.4 naar 8.2: 60.000 regels, vier auteurs, geen testsuite. De zes-stappen-pass die deprecations en stille fouten vindt.

Bovenaanzicht op linnen: PHP 7.4 naar 8.2 werkblad, checklist, manilla map, messing plaatje, rode lakzegel op envelop.
Hero · gestileerd stilleven№ 082

Vrijdag, 17:50. Een lead bij een Nederlands bureau stuurt een mail door van zijn hostingmaatschappij. PHP 7.4 verdwijnt over zes weken van shared hosting. Hun boekingsplatform is ruwweg 60.000 regels custom PHP, geschreven tussen 2014 en 2022 door vier mensen, van wie er twee niet meer reageren op LinkedIn. Er is een tests-map. Daar staan vier bestanden in. Eén ervan heet test_test.php en print "ok".

Dit is een vrij gemiddelde upgrade van PHP 7.4 naar 8.2. De codebase compileert. Hij draait grotendeels. En ergens daarbinnen gaat een strlen($name), waarbij $name soms null is, TypeErrors gooien zodra je de runtime omzet. De klus is om die te vinden voor de overgang, niet erna. Wat volgt is de compatibility pass in zes stappen die wij draaien op elke PHP 8.2-migratie, in de volgorde die de minste tijd verspilt.

Stap 1: Inventariseer voor je één regel aanpast

Het eerste uur is geen code aanpassen. Het is tellen. Je hebt drie getallen nodig voor je iets beslist: hoeveel PHP je daadwerkelijk hebt, welke runtime hij nu target, en wat je dependency graph denkt te ondersteunen.

find . -name '*.php' -not -path './vendor/*' | xargs wc -l | tail -1
grep -r 'php_version\|PHP_VERSION_ID' --include='*.php' .
composer why-not php 8.2.0

Dat laatste commando verrast mensen. composer why-not php 8.2.0 loopt elk pakket in je composer.lock langs en vertelt welke nog een plafond onder 8.2 vasthouden. Bij het project van het Nederlandse bureau was de boosdoener een verlaten PDF-library uit 2018 met een "php": "^7.0"-constraint in zijn manifest, terwijl de code zelf prima draaide onder 8.2. Hem forken en de constraint ophogen kostte twintig minuten. Doen alsof hij niet bestond had een week gekost.

Stap 2: PHPStan als triage, niet als rapportcijfer

Installeer PHPStan op level 0. Weersta elke neiging om bij level 5 te beginnen. Het doel hier is niet de code repareren, het is de code in kaart brengen.

composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src --level=0 --memory-limit=2G --error-format=json > baseline.json

Op een legacy codebase van 60.000 regels levert level 0 ergens tussen de 200 en 2.000 fouten op. Dat is prima. Pipe de JSON-output naar een script, groepeer op error code, en je hebt je echte backlog. De categorieën die er bij een 8.2-upgrade toe doen zijn vrijwel altijd dezelfde drie: impliciet nullable parameters (deprecated in 8.0, uiteindelijk fataal), aanroepen naar interne functies met null-argumenten (strlen(null), trim(null), de null-haystack van str_replace) en dynamische properties (de hoofdpijn-deprecation van 8.2).

Die met de dynamische properties is het meest virulent. Elke klasse die stilletjes $obj->newField = 'x' accepteert zonder $newField te declareren, gooit onder 8.2 een E_DEPRECATED bij elke toewijzing. In een controller die 4.000 requests per minuut afhandelt is dat een logbestand dat in één middag een partitie volpompt.

// Trips E_DEPRECATED in PHP 8.2
class Booking {
    public string $id;
}
$b = new Booking();
$b->customer_email = 'a@b.com';

// Two valid fixes:
#[\AllowDynamicProperties]
class Booking { /* ... */ }

// Or, preferred: declare the property
class Booking {
    public string $id;
    public ?string $customer_email = null;
}

Gebruik #[\AllowDynamicProperties] als tijdelijke wachtkamer voor de ergste gevallen, niet als eindbestemming. Elke klasse die het attribuut draagt is een stukje schuld dat je het volgende kwartaal terugbetaalt.

Stap 3: Rector voor de mechanische fixes

Ruwweg 60% van een diff tussen PHP 7.4 en 8.2 is mechanisch: nullable type hints, de spread-operator op associatieve arrays, str_contains in plaats van strpos !== false, de readonly modifier, constructor property promotion. Rector doet dat allemaal zonder mening. Configureer hem één keer per project:

// rector.php
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withSets([LevelSetList::UP_TO_PHP_82])
    ->withImportNames(removeUnusedImports: true);

Draai eerst vendor/bin/rector process --dry-run. Lees de diff. Op het boekingsplatform produceerde hij een patch van 38.000 regels. Dat is geen getal dat je in één commit landt. Splits het: types eerst, syntax-modernisering tweede, dode-code-opruim als laatste. Elke commit is een aparte PR met één reviewer.

Rector mist soms dingen die voor de hand lijken te liggen. Match-expressies in plaats van switch-statements met fall-through bijvoorbeeld worden niet herschreven, omdat de semantiek net iets anders is. Dat is correct gedrag. Het doel is geen 100% modernisering; het doel is een werkende 8.2-runtime.

Stap 4: De deprecation-ronde die grep voor je vindt

Sommige 8.2-deprecations worden niet opgepikt door PHPStan of Rector omdat ze eruitzien als gewone string-bewerkingen. Drie die je met de hand moet greppen:

grep -rn 'utf8_encode\|utf8_decode' src/
grep -rn '\${' src/ | grep -v '//'
grep -rn 'mb_convert_encoding.*HTML-ENTITIES' src/

De eerste vindt aanroepen naar utf8_encode en utf8_decode, in 8.2 deprecated ten gunste van de expliciete mbstring-vorm. Veel verouderde WordPress- en Joomla-codebases gebruiken ze als een soort magische talisman tegen latin-1-invoer uit oude MySQL-tabellen. Vervang elke aanroep door mb_convert_encoding($s, 'UTF-8', 'ISO-8859-1'), maar lees de volgende callout voor je dat doet.

De tweede grep vindt de deprecated "${var}"-interpolatiesyntax. De fix is de accolade-vorm "{$var}", die in elke PHP-versie sinds 5 heeft gewerkt. De derde vindt aanroepen naar mb_convert_encoding($s, 'HTML-ENTITIES', 'UTF-8'), deprecated in 8.2, en die de meeste senior PHP-ontwikkelaars minstens één keer hebben geschreven. De vervanging is htmlentities($s, ENT_QUOTES, 'UTF-8').

Stap 5: De encoding- en database-laag

De bug die bij een PHP 8.2-upgrade naar productie gaat zit vrijwel nooit in PHP zelf. Hij zit in de kolom tussen PHP en MySQL. PDO-gedrag rond null-waarden, de wisselwerking tussen strict_types en fetch, en de default SET NAMES schuiven op manieren die er in dev prima uitzien en in productie stuk gaan.

Drie checks voor de overgang. Eén: elke PDO-connectie hoort expliciet PDO::ATTR_EMULATE_PREPARES => false en PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC te zetten. Default-emulatiegedrag rond gebonden integers is geluidloos veranderd tussen minor-versies, en een numeriek ID dat vergeleken wordt met een string-getypte primary key kan nu nul rijen matchen waar het er eerst één matchte.

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_EMULATE_PREPARES   => false,
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4",
]);

Twee: doorzoek je schema op utf8 (de driebyte-alias). MySQL's utf8 is geen echte UTF-8 en is dat ook nooit geweest. Elke kolom die een emoji of een Chinees karakter moet kunnen bevatten heeft utf8mb4 nodig. De conversie is één statement per tabel, maar hij lockt, dus plan hem in: ALTER TABLE bookings CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;.

Drie: scan je codebase op SQL die met (int)-casts op user-input wordt opgebouwd via concatenatie. PHP 8 heeft veranderd hoe strings met getallen vergeleken worden, en de oude defensieve cast "WHERE id = " . (int) $_GET['id'] werkt nog steeds, maar beschermt niet meer tegen het lek waarvoor hij geschreven is. Zet die om naar bound parameters, één query tegelijk.

Stap 6: De canary, niet de big-bang

Zet de runtime nooit overal tegelijk om. Het patroon dat werkt: deploy de 8.2-fix-branch achter een runtime-selector, route een klein percentage van het verkeer ernaartoe, en kijk twee werkdagen mee in de error log. Op shared hosting betekent dat vaak een tweede subdomein dat naar dezelfde DocumentRoot wijst met een .htaccess-override:

# /canary/.htaccess on cPanel-style hosts
AddHandler application/x-httpd-php82 .php
<IfModule mod_suphp.c>
    suPHP_ConfigPath /home/user/etc/php82
</IfModule>

Stuur eerst je eigen sessie ernaartoe. Daarna intern personeel. Daarna 5% van het klantverkeer, 48 uur lang. Laat de PHP error log meelopen met grep -E 'Deprecated|TypeError|Fatal' in een tmux-pane, de hele periode. Twee schone dagen is je groene licht; alles daaronder gaat terug naar stap 2 met het nieuwe bewijs.

Wat dit met de codebase doet

Zes stappen, twee à drie weken geconcentreerd werk, en een 60.000 regels tellende verouderde codebase die de komende vier jaar op een ondersteunde runtime draait. De Rector-commits laten je bovendien achter met constructor property promotion, readonly properties op je value objects en enum-getypte statusvelden. Dat was geen van allen het doel, maar het is wel het dividend.

Toen wij Pier bouwden liepen we tegen precies deze lus aan op de legacy site van een klant met een PHP 7-base van 70.000 regels, en het pijnlijke stuk waren nooit de deprecations zelf. Het was het terugtraceren van een regressie drie dagen na deploy naar één Rector-aanpassing op één regel, en daarom wordt elke wijziging die Pier maakt vastgelegd in version history en is elke databasewijziging via de MySQL editor één klik verwijderd van de vorige staat.

De kleinste stap voor vandaag: draai composer why-not php 8.2.0 en vendor/bin/phpstan analyse --level=0 op je grootste module voor het einde van de werkdag. De output vertelt je of volgende week een sprint of een kwartaal wordt.

— Vragen —

Kunnen we 8.0 en 8.1 overslaan en direct van 7.4 naar 8.2 springen?

Ja. De runtime-upgrade is één stap. Wat telt is de deprecations van elke tussenliggende versie oplossen, en daar pakt Rectors UP_TO_PHP_82-set er in één keer doorheen.

Hoe lang duurt dit op een codebase van 60.000 regels?

Twee tot drie geconcentreerde weken voor één ontwikkelaar, aannemende dat er geen testsuite herbouwd hoeft te worden. De encoding- en database-audit is meestal de traagste stap, niet de PHP zelf.

Hebben we een echte testsuite nodig voor we upgraden?

Het helpt, maar de canary-deploy in stap 6 is het alternatief. Statische analyse plus een klein stukje live verkeer vangt meer 8.2-specifieke regressies dan een verouderde PHPUnit-run uit 2019.