DEV Community

Bo Zeng
Bo Zeng

Posted on

Two real WordPress plugins on PHP 8.4: what broke, and what I refused to auto-fix

PHP 8.2 stops getting security fixes on December 31, 2026. PHP 8.3 already left active support at the end of 2025. If you maintain a WordPress plugin, a theme, or an older PHP app, the hosting company is going to move you to 8.3 or 8.4 sooner or later, whether the code is ready or not.

The hard part of that move is rarely "does it compile". It is three questions at once:

  1. What actually breaks on the new version?
  2. What can I fix automatically without changing behavior?
  3. How do I fix it without dropping the old PHP versions my users still run?

That third question is the one most tools skip, and it is why I built Refit, a small open-source CLI with two commands: refit scan and refit fix.

Why not just run Rector?

Rector is excellent at rewriting PHP safely, and PHPCompatibility knows every removed and deprecated PHP feature. Refit runs both, plus php -l. It does not reinvent the checks.

The catch is Rector's PHP version sets. They don't only remove breakages, they also modernize: match, readonly, str_contains. That is great for an app that only runs on PHP 8. It is a disaster for a plugin whose header says Requires PHP: 7.2, because the "upgraded" code now fails on the PHP version your users are still on.

So Refit does three things differently:

  • It reads the oldest PHP version the code must keep supporting from the plugin or theme header (Requires PHP), readme.txt or composer.json.
  • It only uses a curated list of compatibility rules: 54 in total, 52 taken from Rector and 2 of its own.
  • Each rule runs only when the problem exists on your target version and the code it writes still runs on your minimum version.

Real report 1: an events plugin

Here is what refit scan found in a WordPress events plugin with 50 PHP files, targeting PHP 8.4. These numbers come straight from its Markdown report:

Your code Bundled libraries
Breaks 0 0
Check 17 0
Deprecated 5 0
Fixed by refit fix 4 not changed

Zero fatal errors, which is good news. But 17 spots can fail with unexpected input, and all of them are the same thing.

The PHP 8.3 change nobody talks about

Since PHP 8.3, DateTime::modify() throws a DateMalformedStringException when it can't parse the string. Before, it returned false with a warning. So this very common pattern, taken from Refit's test fixture:

function legacy_next_date($when)
{
    $date = new DateTime();
    if (false === $date->modify($when)) {
        return null;
    }
    return $date;
}
Enter fullscreen mode Exit fullscreen mode

still compiles, uses no removed function, and now dies with an uncaught exception the first time $when is an empty string from a URL or a saved setting.

Nothing in that code is "removed" or "deprecated", so a check for removed features has nothing to report. Refit ships its own PHP_CodeSniffer sniff for it. To keep the noise down, it only reports ->modify() calls that:

  • are in a file that works with DateTime objects,
  • get an argument built at run time (a fixed string like '+1 day' either always works or always fails in testing),
  • and are not already inside a try block.

This version from the same fixture is not reported:

function legacy_safe_date($when)
{
    try {
        return (new DateTimeImmutable())->modify($when);
    } catch (Exception $e) {
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

These are reported as Check, not Breaks, because whether they fail depends on the data. A person has to decide what the right fallback is, so Refit doesn't try to write one.

Dynamic properties, and the WP_Widget problem

The 4 auto-fixable items in the events plugin are dynamic properties, deprecated in PHP 8.2. Refit declares them in the class as plain public properties. That works when the parent class is in your code. When it isn't, as with this class from Refit's test fixture:

class Legacy_Widget extends WP_Widget
{
    public function __construct()
    {
        $this->widget_title = 'Legacy';
    }
}
Enter fullscreen mode Exit fullscreen mode

Refit can't see WP_Widget, so it can't know whether declaring the property would clash with it. It adds #[\AllowDynamicProperties] instead. It does the same when the class or one of its parents defines __get, __isset or __unset, because a declared property would stop those magic methods from running for that name. Rector's own rule for this (CompleteDynamicPropertiesRector) is deprecated, so this is one of Refit's two own rules.

Problems that were already there

The report also has a section called "Already a problem on PHP 7.2". For the events plugin it holds 6 findings: mysql_* calls in the debug page and $this used in a plain function. These already fail on the oldest PHP version the plugin supports, so the upgrade doesn't change them. Keeping them apart means the headline answers the question you actually asked: what does moving to 8.4 break?

Real report 2: a social feed plugin, and the fix Refit won't make

A social media feed plugin (46 PHP files) came back almost clean:

Runs on PHP 8.4, with 1 deprecation warning that will fill the error log and break in a later PHP version.

The one problem is an implicitly nullable parameter, deprecated in PHP 8.4:

Implicitly marking a parameter as nullable is deprecated since PHP 8.4. Update
the type to be explicitly nullable instead. Found implicitly nullable parameter:
$next_page_token.
Enter fullscreen mode Exit fullscreen mode

The fix is trivial: write ?Type $x = null. Rector has a rule for it. But the report says "Fixed by refit fix: 0".

That's on purpose. The plugin declares PHP 7.0 as its minimum, and the ?Type syntax needs PHP 7.1. Writing it would turn a deprecation notice on PHP 8.4 into a parse error on PHP 7.0. So Refit reports the issue, explains the fix, and leaves the decision to the maintainer: raise the minimum to 7.1, or live with the notice for now.

Fixing without breaking things

refit fix applies the safe fixes in place. It's deliberately careful:

  • It refuses to run unless the project is a git repository with no uncommitted changes, so you can always git diff and undo. (--force skips this.)
  • It runs php -l on every changed file before and after. A file that compiled before and doesn't after is restored to the original.
  • Bundled libraries (Composer vendor/, Freemius, TCPDF, Action Scheduler, CMB2 and others) are left alone unless you pass --include-libraries. The fix there is to update the library.
  • Tabs or spaces are kept the way the project uses them.

The test suite runs scan and fix end to end on a small sample plugin. Part of it looks like this before the fix:

public function save(array $data = null)
{
    $first = $data{0};
    return implode($data, ',');
}
Enter fullscreen mode Exit fullscreen mode

After refit fix, the test checks that $data{0} became $data[0], that implode($data, ',') became implode(',', $data), that the parameter became ?array $data = null (the fixture declares PHP 7.2, so ?Type is allowed), that create_function() and each() are gone, that the bundled library file is byte-for-byte unchanged, and that every changed file still passes php -l.

Rector rules I removed after they broke real code

This is the part I found most interesting. I started with Rector's per-version rules and ran them against real WordPress plugins. These five changed behavior, so Refit doesn't use them:

  • SensitiveConstantNameRector upper-cases constants it can't find a define() for. That breaks mixed-case constants defined in another file.
  • AssignArrayToStringRector turns $x = '' into $x = [] even when $x is later used as a string. It broke a SQL builder.
  • RemoveExtraParametersRector drops arguments that functions may read with func_get_args().
  • WrapVariableVariableNameInCurlyBracesRector restores PHP 5 semantics in code that has been running on PHP 7 ever since.
  • StringifyStrNeedlesRector only adds (string) casts that PHP 8 already implies.

None of these are bugs in Rector. They're correct for the code Rector expects. Plugin code that has been patched across ten PHP versions just isn't that code.

What it can't do

Refit is static analysis. It never runs your code or loads your autoloader, so it can't see problems that depend on data, like passing null to a string function on PHP 8.1. There's an opt-in --all-rules flag that casts those arguments everywhere, but it's noisy, so it's off by default.

It also only scans .php, .inc and .phtml files, so Drupal's .module and .install files are not checked yet. And PHPCompatibility's PHP 8 checks only exist in its 10.0 alpha, which is why the install command has to name it.

Run your tests and click through the plugin on the target PHP version after fixing. The report says so too.

Try it

composer require --dev qiyuanlabs/refit "phpcompatibility/php-compatibility:^10.0@alpha"

vendor/bin/refit scan --php=8.4 --html=report.html
Enter fullscreen mode Exit fullscreen mode

--markdown and --json work the same way. In CI, refit scan --php=8.4 --fail-on=breaks exits with code 1 when your own code has anything that breaks on the target version. It needs PHP 8.1 or newer to run, and the code you scan can target anything from PHP 5.3 up.

It's MIT licensed and at version 0.1.1, so I'd really like to hear where it's wrong: false positives from the DateTime check, rules you think are missing, or plugins where a fix changed behavior. Issues are open on GitHub.

I've been writing PHP for 18 years, and these days I also do fixed-price PHP upgrades at QiYuan Labs for the projects that need more than a tool.

Top comments (1)

Collapse
 
rushdeploy profile image
Rishabh Mishra •

The minimum-version rule is the part I would keep. Refusing a fix that breaks PHP 7.0 users is a better trade than clearing a deprecation on 8.4. The DateTime modify example shows why a scan alone is not enough without checking the data path.