
The short version of the story: the child theme of my old blog passed a comment callback as 'callback' => mytheme_comment, without quotes. On PHP 7 that was a warning and the string "mytheme_comment". On PHP 8 it is a fatal Error, and every blog post with comments died. I told the full story in an earlier post. This one is about the question that came after: which tool would have told me before the deploy?
I did not want to answer from memory, so I ran the tools against a minimal example and against the real old comments.php from git. Versions: PHP 8.4 CLI, PHPStan 2.3.1, PHP_CodeSniffer 3.13 with PHPCompatibility 9.3.5 and 10.0.0-alpha2.
The bug in ten lines
<?php
function mytheme_comment($comment, $args, $depth) { echo 'x'; }
function render_list(): void {
$options = array(
'style' => 'ol',
'callback' => mytheme_comment,
);
var_dump($options['callback']);
}
render_list();PHP 7.4 prints Warning: Use of undefined constant mytheme_comment - assumed 'mytheme_comment' (this will throw an Error in a future version of PHP) and carries on. PHP 8.4 prints Fatal error: Uncaught Error: Undefined constant "mytheme_comment". The future arrived.
php -l: only syntax, and only for the PHP you run it with
$ php -l src/comments.php
No syntax errors detected in src/comments.phpThat is correct: it is valid syntax. A bare word is a constant lookup, and whether that constant exists is only known at runtime. php -l on the real old comments.php said the same. So the linter is not useless, it just answers a different question.
Where php -l does help: syntax that PHP 8 removed. The old string offset with curly braces is a parse error now:
$ php -l syntax/curly.php # contains: echo $s{0};
Parse error: syntax error, unexpected token "{", expecting "," or ";" in syntax/curly.php on line 3The important detail: run the linter with the target PHP version. php -l on your old PHP 7.x server will happily accept that file. In DDEV that is easy, set the target version and lint everything once:
ddev exec 'find public/blog/wp-content/themes -name "*.php" -print0 | xargs -0 -n1 php -l | grep -v "^No syntax errors"'PHPStan: it finds it, but not at level 0
PHPStan analyses the code without running it, so it knows which functions and constants exist. On the ten-line example:
--- level 0
src/more.php:5:Deprecated in PHP 8.0: Required parameter $b follows optional parameter $a.
src/more.php:5:Function each not found.
src/more.php:6:Function create_function not found.
--- level 1
src/comments.php:7:Constant mytheme_comment not found.
...Look closely: in my run, level 0 did not report the undefined constant, level 1 did. If you introduce PHPStan to a legacy codebase at level 0 to keep the noise down, this class of bug still slips through. Start at level 1 at least.
On the real WordPress theme, the first run is noise: every WordPress function is “not found”, because PHPStan does not know WordPress. The fix is the szepeviktor/phpstan-wordpress extension, which brings the WordPress stubs. With it, the old comments.php at level 1 gives five lines, and every one of them is worth reading:
theme/comments.php:29:Function twentyfifteen_comment_nav not found.
theme/comments.php:45:Constant mytheme_comment not found.
theme/comments.php:93:Array has 2 duplicate keys with value 'label_submit' ('label_submit', 'label_submit').
theme/comments.php:110:Variable $user_identity might not be defined.
theme/comments.php:133:Array has 2 duplicate keys with value 'title_reply' ('title_reply', 'title_reply').Line 45 is our fatal error. Line 29 is a function from the parent theme (Twenty Fifteen); tell PHPStan about the parent theme folder and it disappears. The duplicate keys and the possibly undefined variable are real sloppiness from 2015, the same family as the undefined $aria_req I fixed in that file during the move. The configuration for that run is tiny:
# phpstan.neon
parameters:
level: 1
paths:
- public/blog/wp-content/themes/myblogWith phpstan/extension-installer the WordPress extension registers itself, no include needed. Higher levels find more PHP 8.1 problems: at level 5, strlen(null) is reported as “Parameter #1 $string of function strlen expects string, null given”, which is exactly the PHP 8.1 deprecation for passing null to internal functions.
PHPCompatibility: good for removed functions, blind to this bug
PHPCompatibility is a PHP_CodeSniffer standard that knows which functions and syntax changed between PHP versions. It does not know your code’s constants, so it did not report mytheme_comment in either version, and on the real comments.php it reported nothing.
It also matters which version you install. The latest stable release, 9.3.5, is from before PHP 8 and told me only half the truth:
# 9.3.5, testVersion 8.0-
more.php:5:38: warning - Function each() is deprecated since PHP 7.2; Use a foreach loop instead
# 10.0.0-alpha2, testVersion 8.0-
more.php:5:17: warning - Declaring an optional parameter before a required parameter is deprecated since PHP 8.0. ...
more.php:5:38: error - Function each() is deprecated since PHP 7.2 and removed since PHP 8.0; Use a foreach loop or ArrayIterator instead“Deprecated” versus “removed” is the difference between a log line and a white page. If you use PHPCompatibility for PHP 8 work, use the 10.x line (still alpha when I wrote this) and run it with --runtime-set testVersion 8.0-.
What I run now
php -lwith the target PHP version over all files. Cheap, catches removed syntax.- PHPStan at level 1 or higher, with stubs for the framework (WordPress stubs, or the TYPO3 core in
vendor/for extensions). Catches unknown constants, functions and classes. - PHPCompatibility 10.x for removed and deprecated functions and syntax across versions.
- And then still: open every template type once on the target PHP version, with error logging on. On hoja.in the post pages with comments were the ones that died; a smoke test that only loaded the blog home would have passed.
The PHP 8 changes that bite legacy themes and extensions most
- PHP 8.0: undefined constants throw
Error(our bug);each(),create_function()and PHP 4 style constructors are gone;$str{0}is a parse error; a required parameter after an optional one is deprecated;0 == "abc"is now false. - PHP 8.1: passing
nullto non-nullable parameters of internal functions (strlen,trim,htmlspecialchars) is deprecated. Old WordPress themes do this everywhere. - PHP 8.2: dynamic properties are deprecated, and so is
"${var}"string interpolation. - PHP 8.4: implicitly nullable parameter types (
Foo $x = null) are deprecated.
Deprecations do not break the page today, but each one is a fatal error in a future major version. Fix them while the tools point at them.
If this post helped you, I will be really happy. Cheers!