Most failed PHP upgrades fail the same way: someone bumps the version on staging, the site white-screens, the log fills with fatals, and after two days of whack-a-mole the change gets rolled back and the ticket goes quiet for a year. The problem was not the upgrade. The problem was starting it without an inventory.
An inventory is a file-by-file, line-by-line list of everything in the codebase that the target PHP version will reject or has deprecated. You can produce one in an afternoon on a codebase you have never seen, without running the application, and before anyone touches a server. Here is how we do it.
Know your target before you scan
As of September 2026 the supported branches are PHP 8.2 (security fixes only, ending 31 December 2026), 8.3, 8.4, and 8.5. If you are on 7.4 or 8.0, the useful target is 8.3 or 8.4 — 8.4 gives you the longest runway, 8.3 is the smaller hop if a dependency is not ready. Do not target 8.5 on a first migration off an EOL branch; you want the version your framework, extensions, and hosting all already support, not the newest one.
Pick one target and scan against it. Scanning "to PHP 8" in the abstract produces a list nobody can act on.
Step 1: the compatibility scan
PHPCompatibility is a ruleset for PHP_CodeSniffer that flags syntax and API usage that breaks on a given version. Install it isolated so you are not fighting the legacy app's own dependency tree:
composer global require squizlabs/php_codesniffer phpcompatibility/php-compatibility
phpcs --config-set installed_paths ~/.composer/vendor/phpcompatibility/php-compatibility
Then scan your source, excluding vendor code (you will handle dependencies separately) and anything generated:
phpcs -p --standard=PHPCompatibility \
--runtime-set testVersion 8.4 \
--extensions=php,inc,module \
--ignore=*/vendor/*,*/cache/*,*/node_modules/* \
--report=summary ./
The summary report gives you counts per file — that is your map. Re-run with --report=full on the worst offenders to read the actual lines.
On a typical 7.x codebase the findings sort into four piles:
- Removed things that are hard fatals.
each(),create_function(),money_format(), themysql_*functions,$HTTP_RAW_POST_DATA, curly-brace string offsets ($s{0}). These must be fixed; the file will not even compile in some cases. - Signature and behaviour changes. Implicit nullable parameters (
function f(int $x = null)) are deprecated as of 8.4 and want?int.strlen(null),trim(null)and friends are deprecated null-to-non-nullable arguments. These will not stop the app today but will fill your logs and break later. - Stricter type juggling from PHP 8.0.
0 == "foo"is nowfalse. No scanner catches all of this, because it is runtime behaviour, not syntax — which is why step 4 exists. - Extensions. Scan for
ext-*usage too:mcryptis gone,mssqlis gone,oci8andldapmay need rebuilding.
Count the piles, note the biggest files, and you now have an estimate you can defend rather than a guess.
Step 2: scan the dependencies separately
Your own code is usually the smaller problem. Run:
composer why-not php 8.4
This tells you exactly which packages declare that they cannot run on the target. For each one the answer is: upgrade it, replace it, or fork it. Cross-check against security advisories at the same time — composer audit is built in now, and a package that is both incompatible and carrying a CVE is a package to deal with first, not last.
One trap: a dependency that has no upper bound on PHP in its composer.json is not thereby compatible. Abandoned packages simply never declared anything. composer show --outdated --direct plus the abandoned warnings will tell you which of your dependencies still have a maintainer.
Step 3: let Rector do the mechanical fixes
Most of pile one and pile two is mechanical. Rector applies automated refactoring rules and will make thousands of those edits correctly in seconds:
composer require --dev rector/rector
A minimal rector.php for an upgrade run:
<?php
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/app'])
->withSets([LevelSetList::UP_TO_PHP_84]);
Run it in dry mode first and read the diff:
vendor/bin/rector process --dry-run
Two rules of engagement that keep this safe:
- One version level at a time. Run the 8.0 set, commit, run 8.1, commit, and so on. A single 200-file commit spanning five PHP versions is unreviewable, and when something breaks you will not know which rule did it.
- Upgrade sets only, at first. Rector also ships code-quality and coding-style sets. They are good, but mixing "make this run on 8.4" with "make this prettier" produces a diff where the risky change hides among four hundred cosmetic ones. Modernize later, in its own pull request.
Whatever Rector cannot fix — dynamic eval, string-built class names, per-request global soup — goes on a manual list. That list is the honest part of your estimate.
Step 4: characterization tests, because the scanner cannot see runtime
The changes that hurt on an 8.x upgrade are behavioural: comparison semantics, sorting stability, array_key_exists on objects, floats formatting differently in output. No static tool finds them. What finds them is a test that captures what the application currently does — right or wrong — so you can tell whether your changes altered it.
On a legacy app with no test suite, do not try to unit test it. Test at the edges:
- HTTP-level snapshots. Pick the twenty routes that matter (login, checkout, search, the three biggest reports, the admin pages people actually use). Hit them on the current PHP version, save the response bodies, then hit them on the new one and diff. A short script with
curlanddiffcounts; so does a handful of Behat or Panther cases if the app has sessions. - Output-fixture tests on pure-ish functions. Price calculation, tax, date formatting, CSV and PDF generators. Capture the current output as a fixture, then assert against it. These catch the float and comparison changes that HTTP diffs miss on a happy-path page.
- A log-based deprecation run. Point staging at the new version with
error_reporting(E_ALL)and a log, then replay a day of real traffic from the Apache access log with something likesiege -f urls.txtor a GoReplay capture. Every deprecation notice that fires is a real code path your tests did not cover.
This scaffolding is the part clients are most tempted to skip and the part that determines whether the upgrade is a Tuesday or a fortnight.
Step 5: baseline what you will not fix yet
You will not clear every deprecation, and you should not block the upgrade on it. Generate a baseline so CI stays green on known debt and fails on new debt:
phpcs --standard=PHPCompatibility --runtime-set testVersion 8.4 --report=json > compat-baseline.json
Wire the scan into CI with the baseline, and the same for PHPStan if you add it (--generate-baseline). The rule that matters: the baseline may shrink, never grow. That single CI check is what stops the codebase drifting back toward the state you found it in.
What the inventory buys you
At the end of an afternoon you have: a count of hard failures, a count of deprecations, a list of dependencies that must move, a Rector diff that handles most of it, and a manual list of the genuinely hard files. That is an estimate with numbers in it — "three days of remediation, one dependency with no maintainer, two reports that need fixture tests" — instead of "we should probably upgrade PHP at some point".
The upgrade itself is then the boring part: rehearse it on a restored backup, roll it out behind a warm rollback, and watch the error log. Boring is the goal.
If you want the inventory done for you, that is the first half of our PHP & MySQL upgrade work — tell us your versions and we will tell you what it looks like. If the scan comes back small, we will say so.