— Article — № 082

082 —PHP

PHP 7.4 to 8.2 upgrade: a six-step compatibility pass

A real PHP 7.4 to 8.2 migration: 60,000 lines, four authors, no test suite. The six-step pass that catches deprecations, type errors, and silent breakage.

Overhead photo on bone linen: PHP 7.4 to 8.2 worksheet, deprecations checklist, manila folder, brass plate, wax seal.
Hero · staged still№ 082

Friday, 17:50. A lead at a Dutch agency forwards an email from his hosting provider. PHP 7.4 leaves shared hosting in six weeks. Their booking platform is roughly 60,000 lines of custom PHP, written between 2014 and 2022 by four people, two of whom no longer answer LinkedIn messages. There is a tests folder. It contains four files. One of them is named test_test.php and prints "ok".

This is a fairly average PHP 7.4 to 8.2 upgrade. The codebase compiles. It mostly runs. And somewhere inside it, a strlen($name) where $name is sometimes null will start throwing TypeErrors the moment you switch the runtime. The job is to find those before the cutover, not after. What follows is the six-step pass we run on every PHP 8.2 migration, in the order that wastes the least time.

Step 1: Inventory before you change one line

The first hour is not editing. It is counting. You need three numbers before you make any decision: how much PHP you actually have, what runtime it currently targets, and what your dependency graph thinks it supports.

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

The last command is the one that surprises people. composer why-not php 8.2.0 walks every package in your composer.lock and tells you which ones still pin a ceiling below 8.2. On the Dutch agency's project, the offender was an abandoned PDF library from 2018 with a "php": "^7.0" constraint in its manifest, even though the actual code ran fine under 8.2. Forking it and bumping the constraint took twenty minutes. Pretending it didn't exist would have cost a week.

Step 2: PHPStan as triage, not as a grade

Install PHPStan at level 0. Resist every urge to start at level 5. The point here is not to fix the code, it is to map it.

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

On a 60,000-line legacy codebase, level 0 will return somewhere between 200 and 2,000 errors. That is fine. Pipe the JSON output to a script, group by error code, and you have your real backlog. The categories you care about for an 8.2 upgrade are almost always the same three: implicit nullable parameters (deprecated in 8.0, fatal eventually), calls to internal functions with null arguments (strlen(null), trim(null), str_replace's null haystack), and dynamic properties (the headline deprecation of 8.2).

The dynamic-property one is the most viral. Any class that quietly accepts $obj->newField = 'x' without declaring $newField will throw E_DEPRECATED on every assignment under 8.2. In a controller that handles 4,000 requests a minute, that is a logfile that fills a partition in an afternoon.

// 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;
}

Use #[\AllowDynamicProperties] as a temporary holding pen for the worst offenders, not a destination. Each class that wears it is a small piece of debt you will pay back in the next quarter.

Step 3: Rector for the mechanical fixes

Roughly 60% of a PHP 7.4 to 8.2 diff is mechanical: nullable type hints, the spread operator on associative arrays, str_contains in place of strpos !== false, the readonly modifier, constructor property promotion. Rector handles all of it without an opinion. Configure it once 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);

Run vendor/bin/rector process --dry-run first. Read the diff. On the booking platform it produced a 38,000-line patch. That is not a number you commit in one go. Split it: types first, syntax modernisation second, dead-code removal last. Each commit is a separate PR with one reviewer.

Rector will sometimes miss things that look obvious. Match expressions over switch statements with fall-through, for example, are not rewritten because the semantic isn't quite identical. That is correct behaviour. The point is not 100% modernisation; the point is a working 8.2 binary.

Step 4: The deprecation tour that grep finds

Some 8.2 deprecations are not caught by PHPStan or Rector because they look like ordinary string operations. Three to grep for by hand:

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

The first finds calls to utf8_encode and utf8_decode, deprecated in 8.2 in favour of the explicit mbstring form. Many legacy WordPress and Joomla codebases use them as a kind of magic talisman against latin-1 input from old MySQL tables. Replace each one with mb_convert_encoding($s, 'UTF-8', 'ISO-8859-1'), but read the next callout before you do.

The second grep finds the deprecated "${var}" interpolation syntax. The fix is the curly-brace form "{$var}", which has worked in every PHP version since 5. The third finds calls to mb_convert_encoding($s, 'HTML-ENTITIES', 'UTF-8'), which is deprecated in 8.2 and which most senior PHP developers have written at least once. The replacement is htmlentities($s, ENT_QUOTES, 'UTF-8').

Step 5: The encoding and database layer

The bug that ships to production on a PHP 8.2 upgrade is almost never in PHP. It is in the column between PHP and MySQL. PDO behaviour around null values, the strict-types interaction with fetch, and the default SET NAMES all shift in ways that look fine in dev and break in production.

Three checks before cutover. First, every PDO connection should set PDO::ATTR_EMULATE_PREPARES => false and PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC explicitly. Default emulation behaviour around bound integers changed quietly between minor versions, and a numeric ID compared against a stringly-typed primary key can now match zero rows where it used to match one.

$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",
]);

Second, audit your schema for utf8 (the three-byte alias). MySQL's utf8 is not real UTF-8 and never was. Any column that needs to hold an emoji or a Chinese character needs utf8mb4. The conversion is one statement per table, but it's locking, so schedule it: ALTER TABLE bookings CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;.

Third, scan your codebase for SQL built by concatenation with (int) casts on user input. PHP 8 changed how strings compare to numbers, and the old defensive cast "WHERE id = " . (int) $_GET['id'] still works but no longer protects against the bug it was written for. Convert these to bound parameters one query at a time.

Step 6: The canary, not the cutover

Never flip the runtime everywhere at once. The pattern that works: deploy the 8.2-fixed branch behind a runtime selector, route a small percentage of traffic to it, watch the error log for two business cycles. On shared hosting that often means a second subdomain pointing at the same DocumentRoot with an .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>

Send your own session there first. Then internal staff. Then 5% of customer traffic for 48 hours. Tail the PHP error log with grep -E 'Deprecated|TypeError|Fatal' running in a tmux pane the entire time. A clean two-day window is your green light; anything else goes back to step 2 with the new evidence.

Where this leaves the codebase

Six steps, two to three weeks of focused work, and a 60,000-line legacy codebase that runs on a supported runtime for the next four years. The Rector commits also leave you with constructor property promotion, readonly properties on your value objects, and enum-typed status fields. None of that was the goal, but it is the dividend.

When we built Pier we ran into this exact loop on a customer's legacy site running a 70,000-line PHP 7 base, and the painful part was never the deprecations themselves. It was tracing a regression three days after deploy back to one Rector edit on one line, which is why every edit Pier makes is captured in version history and every database change made through the MySQL editor is one click away from the prior state.

Today's smallest move: run composer why-not php 8.2.0 and vendor/bin/phpstan analyse --level=0 on your largest module before close of business. The output tells you whether next week is a sprint or a quarter.

— Questions —

Can we skip 8.0 and 8.1 and jump straight from 7.4 to 8.2?

Yes. The runtime upgrade is one step. What matters is fixing the deprecations from each intermediate version, which Rector's UP_TO_PHP_82 set handles in one pass.

How long does this take on a 60,000-line codebase?

Two to three focused weeks for one developer, assuming no test suite to rebuild. The encoding and database audit is usually the slowest step, not the PHP itself.

Do we need a real test suite before upgrading?

It helps, but the canary deploy in step 6 is the substitute. Static analysis plus a small live traffic slice catches more 8.2-specific regressions than a stale PHPUnit run from 2019.

What about Composer packages that still pin PHP 7.4?

Run composer why-not php 8.2.0 first. Most ceilings are stale manifest constraints, not real incompatibilities. Fork, bump, and submit upstream when the package is alive.