Upgrading PHP Across WordPress Sites Safely: 30 Sites, Real Breakages, and a Forced 8.4 Upgrade

· 13 min read

The Clock Was Ticking

PHP 8.1 reached end of life on 31 December 2025. No more security patches, no more bug fixes. Hosting providers started sending increasingly urgent emails about forced upgrades in early 2026 — some gave 30 days notice, others just switched.

I had 30 client sites still running PHP 8.1 across four servers. Some were simple brochure sites. Others were WooCommerce stores processing hundreds of orders a day. Testing each one manually — clicking through pages in a browser, hoping nothing looked wrong — was not an option.

I needed a systematic approach. Here is the process I built, the three breakages that no compatibility scanner caught, and — because I have since lived through it — what happens when a host upgrades PHP without you and floods a server with 14GB of deprecation notices.

Step 1: The Audit

First, I needed to know exactly what I was dealing with. A WP-CLI loop across all sites on each server gave me the full picture in seconds:

for site in /var/www/*/; do
  echo "=== $(basename "$site") ==="
  wp --path="$site/htdocs" core version 2>/dev/null
  wp --path="$site/htdocs" plugin list --fields=name,version,update --format=table 2>/dev/null
  echo ""
done

I piped the output to a file and immediately spotted trouble: six sites had plugins that had not been updated in over two years. Those were my high-risk targets.

Step 2: Automated Compatibility Scanning

The PHP Compatibility Checker plugin works fine for a single site, but running it through the WordPress admin across 30 sites is painful. Instead, I used phpcs with the PHPCompatibilityWP ruleset directly from the command line.

Installation

composer global require phpcompatibility/phpcompatibility-wp:"*"

Batch scanning

for site in /var/www/*/; do
  echo "=== $(basename "$site") ==="
  phpcs -p "$site/htdocs/wp-content/plugins" \
    --standard=PHPCompatibilityWP \
    --extensions=php \
    --runtime-set testVersion 8.3 \
    --report=summary \
    2>/dev/null
  echo ""
done

The --report=summary flag is important — it gives you a per-file error and warning count instead of drowning you in individual notices. When a plugin flags up, drill into it:

phpcs "$site/htdocs/wp-content/plugins/problem-plugin" \
  --standard=PHPCompatibilityWP \
  --extensions=php \
  --runtime-set testVersion 8.3

Across 30 sites the scanner found issues in 14 plugins. Most were deprecation warnings that would not cause immediate breakage. But three were genuine problems that needed fixing before the upgrade.

The Three Breakages I Did Not Expect

1. utf8_encode() in an Abandoned Plugin

One site used a plugin that called utf8_encode() to parse data from a remote API response. PHP 8.2 deprecated both utf8_encode() and utf8_decode() — they will be removed entirely in a future PHP version.

The scanner flagged it. The plugin had not been updated in 18 months and the developer had gone quiet. The fix was a one-line change in the plugin file:

// Before (deprecated in PHP 8.2+)
$data = json_decode(utf8_encode($result), true);

// After
$data = json_decode(mb_convert_encoding($result, 'UTF-8', 'ISO-8859-1'), true);

Since the plugin was effectively abandoned, editing it directly was acceptable — there was no future update that would overwrite my fix. I added a comment with the date and reason so future me (or any developer inheriting the site) would understand why.

For plugins that are still maintained but slow to patch, a safer approach is to fork the plugin into a private repository and track your changes there. That way you can merge upstream updates when they eventually land.

2. Dynamic Properties Flooding the Error Log

Four WooCommerce sites used a shipping calculator plugin that set undeclared class properties throughout its codebase. PHP 8.2 emits a E_DEPRECATED notice for every dynamic property access. On a store handling 200+ orders a day, this generated over 50,000 deprecation notices daily. The error log hit 2GB within a week.

The notices do not break functionality — dynamic properties become fatal errors only in PHP 9.0. But an uncontrolled error log fills the disk, slows down log rotation, and makes it impossible to spot real errors.

I took a two-phase approach:

Phase 1 — Immediate (day of upgrade): Suppress deprecation notices only, keeping errors and warnings visible:

// wp-config.php — temporary measure
error_reporting(E_ALL & ~E_DEPRECATED);

Phase 2 — Next maintenance window: Replace the plugin with a maintained alternative that declared its properties properly. This took more time but was the right long-term fix for production WooCommerce stores.

3. Loose String Comparison in a Custom Plugin

One site had a bespoke pricing plugin written by a previous developer. The phpcs scanner flagged a pattern that revealed this logic:

if ($discount_code == 0) {
    // "No discount" branch
}

PHP 8.0 changed how loose comparisons between strings and integers work. Before PHP 8.0, "SUMMER20" == 0 evaluated to true — meaning any non-empty discount code would hit the "no discount" branch. The site was already on PHP 8.1 so this particular line was already behaving correctly, but the scanner flagged a related comparison in another function that would cause incorrect pricing logic on PHP 8.3.

The fix was strict comparison:

if ($discount_code === '' || $discount_code === null) {
    // "No discount" branch
}

This is exactly why automated scanning is not enough on its own. You need to read the code and understand what it is actually trying to do.

The Rollout

With all issues patched on staging, I upgraded the sites in batches of five over three days. The process for each batch:

1. Staging smoke test

wp --path=/var/www/example/staging/htdocs eval 'echo phpversion();'
curl -sI https://staging.example.com | head -5

2. Switch PHP on production

Update the PHP-FPM pool socket in the nginx vhost, then reload:

# In the nginx site config, change:
#   fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
# to:
#   fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;

nginx -t && systemctl reload nginx

3. Clear the OPcache

wp --path=/var/www/example/htdocs eval 'if (function_exists("opcache_reset")) { opcache_reset(); echo "OPcache cleared\n"; }'

4. Verify

wp --path=/var/www/example/htdocs eval 'echo "PHP " . phpversion() . "\n";'
wp --path=/var/www/example/htdocs cron test

Post-Upgrade Monitoring

For 48 hours after each batch I watched the PHP error logs:

tail -f /var/log/php/example-error.log | grep -E "Fatal|Warning|Parse"

Zero fatal errors across all 30 sites. A handful of deprecation notices from themes using dynamic properties — I logged those as tickets for the next maintenance cycle.

When the Host Upgrades Without You: The PHP 8.4 Log Flood

The process above assumes you control the timing. Sometimes you do not — and a separate incident on another client's server showed me what the failure mode looks like when the host moves first.

A monitoring alert fired on a Friday evening: disk usage on a client's 40GB VPS had jumped from 52% to 94% in three days. The server ran four WooCommerce stores and a handful of brochure sites on CloudPanel. Nothing had changed on the sites themselves. The culprit:

du -sh /var/log/* | sort -rh | head -10
14G /var/log/php8.4-fpm.log

One log file, three days old, consuming a third of the disk. The hosting provider had upgraded the server from PHP 8.3 to 8.4 in a scheduled maintenance window. The code all still worked — but the log was filling with millions of lines like this:

PHP Deprecated: loco_admin_init(): Implicitly marking parameter $hook as nullable is deprecated, the explicit nullable type must be used instead in /var/www/site-a/htdocs/wp-content/plugins/loco-translate/src/admin/init.php on line 42

PHP 8.4 deprecates implicitly nullable parameter types. In PHP 8.3 and earlier, function process_payment(string $token = null) was valid; PHP 8.4 wants the explicit ?string $token = null and emits a deprecation notice on every invocation of the old pattern. WordPress core fixed most of its instances in 6.7 (Trac #60786), but third-party plugins lag — some deliberately, to keep PHP 7.4 compatibility.

Counting unique sources with grep -oP 'in /var/www/\S+' | sort | uniq -c | sort -rn showed seven plugins responsible, led by Loco Translate, WooCommerce PayPal Payments, Action Scheduler, Easy WP SMTP's bundled Action Scheduler copy, and One Click Accessibility. Across four sites averaging 800 combined page views per hour plus WP-Cron and Action Scheduler runs, each request generating 15-40 notices, the log was growing at roughly 200MB per hour.

Containment

Suppress deprecations at the PHP-FPM pool level. In each pool config under /etc/php/8.4/fpm/pool.d/:

; Existing line:
php_admin_value[error_reporting] = E_ALL

; Changed to:
php_admin_value[error_reporting] = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED

Then systemctl restart php8.4-fpm. Fatal errors, warnings, and notices still get logged — only deprecation noise is dropped. On production, deprecation notices belong on staging, not in live logs.

Truncate, do not delete. truncate -s 0 /var/log/php8.4-fpm.log reclaims the space while preserving the file descriptor PHP-FPM holds open. Deleting the file would leave PHP-FPM writing to an unlinked inode, and the space would not actually be freed until a restart.

Add logrotate. The server had logrotate for nginx and MySQL but not the PHP-FPM log — a gap I have seen on CloudPanel, RunCloud, and manual nginx setups alike. I created /etc/logrotate.d/php8.4-fpm:

/var/log/php8.4-fpm.log {
    daily
    rotate 7
    compress
    delaycompress
    missingok
    notifempty
    create 0640 www-data adm
    postrotate
        [ ! -f /run/php/php8.4-fpm.pid ] || kill -USR1 $(cat /run/php/php8.4-fpm.pid)
    endscript
}

The USR1 signal makes PHP-FPM reopen its log after rotation; without the postrotate hook it keeps writing to the rotated file.

The Proper Fix

Suppression is a bandage. The real fix was updating or replacing the offending plugins. WooCommerce had fixed its Action Scheduler deprecations in 9.6.0, WooCommerce PayPal Payments in 3.1.2, and Easy WP SMTP had shipped a fix two weeks earlier — all handled with wp plugin update after a --dry-run review. Loco Translate had no fix available (the author was holding PHP 7.4 compatibility), so it stayed, with the pool-level suppression absorbing the noise. One Click Accessibility was abandoned, so I replaced it with WP Accessibility, which is actively maintained and PHP 8.4 clean.

The practical risk here is not broken code — implicit nullables only become fatal in PHP 9.0. The risk is a full disk. On a WordPress server that does not just stop logging: it stops MySQL writing, stops PHP-FPM creating sessions, and stops WooCommerce processing orders.

What I Learned

Abandoned plugins are the real risk. WordPress core and major plugins like WooCommerce handle PHP upgrades well. It is the niche plugins last updated in 2023 that cause problems. Before any PHP upgrade, check the "Last updated" date on every installed plugin and flag anything older than 12 months.

Do not trust GUI compatibility checkers alone. The phpcs command-line scanner with PHPCompatibilityWP is faster, scriptable, and catches issues the WordPress admin plugin misses. Once you have it set up, scanning 30 sites takes under an hour. For PHP 8.4 targets, the -s flag surfaces sniff names so you can filter for PHPCompatibility.FunctionDeclarations.RemovedImplicitlyNullableParam specifically.

Batch the rollout. Five sites at a time meant that if something went wrong I could roll back quickly. On a server running CloudPanel or cPanel, switching back to the previous PHP version takes under a minute.

Monitor the error log, not just the front end. A site can look perfectly fine in a browser while silently generating thousands of deprecation notices. Deprecation floods are a failure mode browser testing never shows. After the 8.4 incident I now upgrade a staging pool first and watch the PHP-FPM log size for 24 hours — if it grows faster than 50MB/day, I investigate before promoting to production. I also exclude E_DEPRECATED on all production pools by default, make sure logrotate covers PHP-FPM logs on every server, and set disk usage alerts at 80% rather than 90%. On a 40GB VPS, the gap between "time to investigate" and "server about to crash" is smaller than you think.

PHP 8.1 is dead, and the same clock is now running on 8.2 and 8.3. If your sites are behind, your host will force the switch soon — and you will have no control over the timing. Better to do it on your terms, with testing, than to wake up to a broken store or a full disk on a Monday morning.


Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans → Get in Touch →

Stop Firefighting. Start Maintaining.

I manage 70+ WordPress sites for agencies and businesses. Whether you need ongoing maintenance, emergency support, or a one-off performance fix — I can help.

View Maintenance Plans Get in Touch

Get in Touch to Discuss Your Needs