This post was generated by an AI agent from the maintenance records of the person running this account. The incidents and numbers are real; the wording is not human-written.
I look after old PHP sites in Japan. This autumn I worked on getting three of them onto PHP 8.x, for three different reasons: a critical security release, a server whose PHP changed underneath it (we still don't know who changed it), and a planned jump from 5.5.
Two of them went live on 8.x. The first never did: its launch was called off, so what I say about it comes from the new server before release.
The obvious checks passed more often than they should have. Here is what actually broke, with the numbers.
php -l is not a migration test
One site was going from 7.4 to 8.4. Its code still had PHP 4 habits in it. On the new server, every page that ran PHP stopped with this (class name changed):
Non-static method Database::connect() cannot be called statically Lint never mentions it. Here is a minimal version, which I re-ran on PHP 8.4.26 while writing this:
<?php class Db { function connect() { return "ok"; } } echo Db::connect(); php -l says No syntax errors detected. Running it is a fatal error.
We added static to 221 methods. Check two things before you do that: the method must not touch $this (none of ours did), and if a parent method is static, every override has to be static too, or you get a different fatal.
Old-style constructors are worse, because nothing errors at all:
<?php class Base { public $table = null; function Base() { $this->table = "set"; } } class Item extends Base { function Item() { parent::Base(); } } $i = new Item(); var_dump($i->table); // NULL on PHP 8 A method named after its class stopped being a constructor in PHP 8. It is now an ordinary method that nobody calls, so you get an object that was never set up and no message about it. We renamed 45 of these to __construct, along with the parent::Base( calls.
After those fixes, lint over all 648 files failed on exactly one, a file nothing loads, because of $str{0}. That is a fair picture of what lint is for.
44 pages out of 266
On the second site, PHP went from 7.4 to 8.x with no record of anyone on our side changing it. The client reported that some detail pages were missing images and content.
An empty XML element was being turned into an empty array, and the code passed it to nl2br(). On 7.4 that is a warning and the page carries on. On 8.x it is a TypeError and output stops right there.
It only happened for records with one particular combination of fields: 44 of 266. A spot check of a few URLs could easily have missed it, since that's about 1 page in 6.
We had the API responses saved for all 266 records, so we replayed them through PHP 8.1 and 8.4 in Docker with the network off. Before the fix there were 44 TypeErrors, same as production. After it there were 0, and the other 222 pages came out identical apart from a ?v= timestamp.
The fix was to normalise empty arrays to empty strings right after loading, leaving out the six fields that are nested on purpose.
Diff the output instead of clicking around
The third site went from PHP 5.5 to 8.4. Before switching production, we ran both versions against the same data and compared 135 pages:
- 121 identical
- 4 differing only in a date line
- 10 different, each with a reason we could write down
Reading 10 diffs beats clicking through 135 pages and hoping you notice something.
We also rehearsed the rollback on a test domain. Going back to 5.5 took 3 seconds and going forward to 8.4 took 2. It is a much calmer production switch when you already know those numbers.
Smaller things
- mysqli throws now. Since PHP 8.1 the default is exceptions. Old code that checks for
falseneedsmysqli_report(MYSQLI_REPORT_OFF)until you rewrite it. - Keep errors off the page for real. Old code loves
ini_set('display_errors', 1). In an FPM pool,php_admin_flag[display_errors] = offcannot be overridden from code. - Expect noise. One pass over the first site's pages wrote thousands of Deprecated and Warning lines, 3,978 of them from passing
nulltopreg_match(). Look at fatals first. - Check the timezone. We found this one reviewing the config, not from a failure: if the new config doesn't set
date.timezone, dates run in UTC.
The checklist
This is what I'd run through next time, with commands to find each of these. Run them from the project root.
Before you touch anything
- Write down the current PHP version and how it is installed (distro package, panel, source).
- Find out who or what can change the PHP version. One of our sites changed without us.
- Save real input data you can replay (API responses, a database copy).
- Pick the URLs to compare, including rarely used record types.
What php -l does catch
Syntax such as $str{0} fails here. Lint every file:
find . -name '*.php' -print0 | xargs -0 -n1 php -l | grep -v 'No syntax errors' What php -l does not catch
Functions removed in PHP 8:
grep -rnE '\b(each|create_function|get_magic_quotes_(gpc|runtime))\s*\(' --include='*.php' . Old-style constructors, which silently stop running:
find . -name '*.php' -exec awk ' FNR == 1 { c = "" } match($0, /class +[A-Za-z0-9_]+/) { c = substr($0, RSTART + 6, RLENGTH - 6); gsub(/ /, "", c) } c && match($0, /function +&?[A-Za-z0-9_]+ *\(/) { f = substr($0, RSTART, RLENGTH); gsub(/function +&?| *\(/, "", f) if (tolower(f) == tolower(c)) print FILENAME ":" FNR ": " $0 }' {} + Parent constructors called by class name:
grep -rnE 'parent::[A-Z][A-Za-z0-9_]*\s*\(' --include='*.php' . | grep -v __construct Static calls to methods that are not declared static. This lists the calls, and you check each target. Include templates:
grep -rnE '\b[A-Z][A-Za-z0-9_]*::[A-Za-z_][A-Za-z0-9_]*\s*\(' --include='*.php' --include='*.tpl' --include='*.html' . | grep -vE 'parent::|self::|static::' Smarty {php} tags, which newer Smarty does not allow:
grep -rn '{php}' --include='*.tpl' --include='*.html' . Also check the installed version of old mail libraries such as PHPMailer.
Test with real data
- Replay every saved record through the new version, not just a few URLs.
- Run the old and new versions on the same data and diff the HTML. Ignore cache-busting values such as
?v=. - Give every remaining difference a written reason.
Switch
- Rehearse the rollback on a test domain and time it.
- After the switch, look at fatals first, then work through Warnings and Deprecated.
grep -E 'PHP (Fatal|Parse) error|Uncaught' error.log | sort | uniq -c | sort -rn | head Three sites isn't a study, and your numbers will differ. If you've moved something old to PHP 8 and hit something that isn't on the list, I'd like to hear about it.
Follow @kmrb_works here on DEV for more field notes from real PHP maintenance work. Also on X and note, mostly in Japanese.
