PHP

Finding PHP 8 fatal errors in legacy code before production

5 min read

Catch it before PHP 8 does

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.php

That 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 3

The 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/myblog

With 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

  1. php -l with the target PHP version over all files. Cheap, catches removed syntax.
  2. 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.
  3. PHPCompatibility 10.x for removed and deprecated functions and syntax across versions.
  4. 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 null to 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!

Further reading

Leave a comment

Your email address will not be published.