TLDR

A WordPress major release changed a hook callback identifier from a string to an integer. A caching plugin passed that value straight into substr() inside a file that declares strict_types=1, so PHP threw a TypeError instead of quietly converting it. The call sat on the init hook, which runs on every single request, so the front end, the admin and AJAX all died at the same moment.

Restoring service took one WP-CLI command. The permanent fix was casting one value to a string. The real problem was that a major version update installed itself, unattended, at 3:27 in the morning.

At 3:27 in the morning, a site we look after updated itself to a new major version of WordPress. At 3:28, the uptime monitor went down. Nobody had touched anything. No one had logged in, deployed, or installed a thing.

By the time anyone was awake there was a dead front end, a dead admin area, and an inbox full of automated alarms. The site owner had also noticed the security plugin reporting far more blocked requests than usual, which made the whole thing look a great deal like an attack. Their first message asked whether someone had broken in.

Nobody had broken in. The entire outage came down to one value changing from one data type to another, and the fix was a single word added to a single line. This is the full story, with the actual stack trace, the commands that brought it back, and the checks worth running on your own site before the same thing happens to you.

What a WordPress critical error actually looks like

If you have never met one, a WordPress critical error is the polite white page that says There has been a critical error on this website. Underneath the politeness, the server is returning a 500 status code, which means the page never finished building.

The quickest way to confirm what you are dealing with is to ask the server directly rather than trusting what the browser shows you:

$ curl -s -o /dev/null -w "HTTP %{http_code}  %{time_total}s\n" \
    https://example.com/

HTTP 500  0.820232s

What makes this particular failure frightening, rather than merely annoying, is that it usually takes the admin area with it. You cannot log in to deactivate the thing that broke, because the login page is broken too. That is the moment most people start guessing, and guessing on a live site is how a bad morning becomes a bad week.

For the avoidance of doubt, this was not a hack, and a critical error almost never is. If your own checks point at a compromise rather than an update, that is a different job, and it is covered on our WordPress malware removal page.

The email WordPress sends that almost everyone ignores

Since version 5.2, WordPress has shipped a fatal error handler. When a plugin or theme kills the site, WordPress catches it, works out which plugin was executing at the time, and emails the site admin address. That email is the single fastest route to a diagnosis, and it goes unread constantly because it looks like yet another automated notice.

Here is what arrived that morning, with the identifying details removed. Read the last four lines first:

WordPress has a built-in feature that detects when a plugin
or theme causes a fatal error on your site.

In this case, WordPress caught an error with your plugin,
WP Rocket.

WordPress version 7.1
Active theme: [redacted] Child
Current plugin: WP Rocket (version 3.23.1.1)
PHP version 8.4.10

Four facts, handed over without any investigation at all: the core version, the theme, the exact plugin and its version, and the PHP version. Before touching a server, we already knew which plugin to look at.

The same email contains a recovery mode link. That link lets you log in to a site whose admin is otherwise dead, with the offending plugin held back. If you take one thing from this post: when a site dies, check the admin inbox before you check anything else.

Reading the stack trace

Below the summary, the same email carries the actual error. This is the part people scroll past, and it contains the whole answer:

An error of type E_ERROR was caused in line 562 of the file
/www/example_123/public/wp-content/plugins/wp-rocket/inc/
ThirdParty/Plugins/CDN/Cloudflare.php

Error message: Uncaught TypeError: substr():
Argument #1 ($string) must be of type string, int given

Stack trace:
#0 .../CDN/Cloudflare.php(562): substr(22256, -24)
#1 .../CDN/Cloudflare.php(535):
    Cloudflare->unregister_callback('deleted_post', 'purgeCache...')
#2 .../wp-includes/class-wp-hook.php(353):
    Cloudflare->unregister_cloudflare_clean_on_post('')
#3 .../wp-includes/class-wp-hook.php(377):
    WP_Hook->apply_filters(NULL, Array)
#4 .../wp-includes/plugin.php(523): WP_Hook->do_action(Array)
#5 .../wp-settings.php(779): do_action('init')
#6 .../wp-config.php(74): require_once('...')
#7 .../wp-load.php(50): require_once('...')
#8 .../wp-admin/admin-ajax.php(22): require_once('...')
#9 {main}

Stack traces read from the bottom up. Start at #5 and work backwards. WordPress fires the init hook. That hook calls a method in the caching plugin. That method calls another method in the same plugin. That method calls substr(). And substr() refuses.

Look closely at line #0, because it is the whole bug in one line: substr(22256, -24). That first argument, 22256, should have been a string. It is an integer. PHP said no, and everything above it collapsed.

The bug: one integer meets one strict type

Here is the function, pulled straight off the server. It is doing something entirely reasonable: walking through the list of callbacks registered on a WordPress hook, and removing the one whose name ends with a particular method.

protected function unregister_callback(
    string $hook, string $method, int $priority = 10
) {
    global $wp_filter;

    if ( ! key_exists( $hook, $wp_filter ) ) {
        return;
    }

    $original_wp_filter = $wp_filter[ $hook ]->callbacks;

    if ( ! key_exists( $priority, $original_wp_filter ) ) {
        return;
    }

    foreach ( $original_wp_filter[ $priority ] as $key => $config ) {

        // Line 562. This is where it dies.
        if ( substr( $key, - strlen( $method ) ) !== $method ) {
            continue;
        }

        unset( $wp_filter[ $hook ]->callbacks[ $priority ][ $key ] );
    }
}

That $key is a WordPress hook callback identifier. For most of WordPress history it has been a string, so calling substr() on it was completely safe. In this major release, the identifier comes back as an integer for certain callback types. The 22256 in the trace is an internal object id.

Ordinarily that would not matter in the slightest. PHP is relaxed about this sort of thing and would convert the integer to the string “22256” without comment. Except for what sits on line two of that same file:

<?php
declare(strict_types=1);

namespace WP_Rocket\ThirdParty\Plugins\CDN;

declare(strict_types=1) switches off the automatic conversion for every function call in the file. It is good practice, it catches real bugs, and plenty of careful plugin authors use it. It also means that the moment a value arrives as a type you did not expect, PHP throws a TypeError instead of shrugging and carrying on.

So the plugin was not doing anything careless. It was being strict, and a core change moved the ground underneath it. Both pieces of code are defensible on their own. Together, on the same morning, they took a site off the internet.

Abstract illustration of a value being rejected at a boundary, representing a PHP strict type error.
One value arrives as the wrong type. In a file that declares strict types, PHP refuses it outright rather than converting it.

Why one plugin file killed every page on the site

Plenty of plugin errors break one page, or one form, or the checkout. This one broke everything, and the reason is line #5 of the trace: do_action('init').

The init hook fires on effectively every request WordPress handles. Not just page views. It runs for:

  • every front end page load, including cached misses
  • every admin screen
  • every AJAX call, which is why the trace ends at admin-ajax.php
  • the REST API
  • WP-Cron, so scheduled jobs stop too

A fatal error at that point is not a broken feature, it is a broken site. One hundred percent of requests returned a 500, which is exactly why the login page was unreachable and why the usual advice of “go and deactivate the plugin” was of no use whatsoever.

Step one: get the site back up before you understand it

The temptation when you find something this interesting is to keep pulling the thread. Resist it. Restore service first, investigate second. Every minute spent being curious is a minute the site is down.

The problem is circular: the standard command to deactivate a plugin needs WordPress to load, and WordPress cannot load because of the plugin. The way out is a WP-CLI flag that a surprising number of people have never needed:

$ wp --skip-plugins --skip-themes plugin deactivate wp-rocket

Plugin 'wp-rocket' deactivated.
Success: Deactivated 1 of 1 plugins.

--skip-plugins loads WordPress without executing any plugin code. The fatal never fires, so WP-CLI runs normally and can still write the change to the database. It is the single most useful recovery command in WordPress, and it works when nothing else will.

One extra step for caching plugins specifically. They install a drop-in file that loads before plugins do, and it can throw its own error once the plugin folder it points at is gone. Move it aside rather than deleting it:

$ mv wp-content/advanced-cache.php \
     wp-content/advanced-cache.php.DISABLED-20260820

WordPress checks whether that file exists before loading it, so renaming it is safe. Keep the original rather than removing it, because you will want it back in a few minutes.

That was enough. The site returned 200 across the front end, the login page and admin-ajax.php, which was the URL in the original trace. Total time from reading the email to a working site: under ten minutes. It stayed slower than normal, because the caching layer was now switched off, but slow and alive beats fast and dead.

Site down right now?

If you have landed here mid-outage with a white screen and no admin access, we can help. We work on WordPress sites that have broken after an update, a PHP version change or a plugin conflict, and the first thing we do is get you serving pages again. Tell us what you are seeing and we will tell you what it is.

Get emergency WordPress help

Step two: the one character that fixed it properly

Leaving caching switched off was not a real answer, especially on a site where page speed was already a live conversation. The next question was whether the plugin authors had shipped a compatible release. They had not:

$ wp --skip-plugins plugin list --name=wp-rocket \
     --fields=name,status,version,update

name       status    version     update
wp-rocket  inactive  3.23.1.1    none

No update available. The plugin simply had not caught up with the core release yet. So the choice was to leave caching off for an unknown number of days, or to patch the file.

Before patching anything, check whether the same pattern appears elsewhere in the plugin. Fixing one line and discovering a second fatal thirty seconds later is a poor use of an outage:

$ grep -rn 'substr( *$key' --include='*.php' .

./inc/ThirdParty/Plugins/CDN/Cloudflare.php:562:
    if ( substr( $key, - strlen( $method ) ) !== $method ) {
./inc/classes/dependencies/.../Mobile_Detect.php:928:
    if (substr($key, 0, 5) === 'HTTP_') {
./inc/vendors/classes/class-rocket-mobile-detect.php:719:
    if (substr($key, 0, 5) === 'HTTP_') {

Three matches, but only one that matters. The other two walk through $_SERVER keys, which are always strings, and they live in vendored library files that do not declare strict types. One line to change.

Back the file up, apply the cast, and check the syntax before letting the plugin anywhere near the site again:

# 1. Keep the original somewhere outside the plugin folder
$ cp -p Cloudflare.php ~/Cloudflare.php.ORIG-20260820

# 2. Cast the value to a string
$ sed -i 's/substr( $key,/substr( (string) $key,/' Cloudflare.php

# 3. Confirm the change landed on the right line
$ sed -n '562p' Cloudflare.php
    if ( substr( (string) $key, - strlen( $method ) ) !== $method ) {

# 4. Never reactivate without linting first
$ php -l Cloudflare.php
No syntax errors detected in Cloudflare.php

The whole fix, expressed as a diff, is this:

- if ( substr( $key, - strlen( $method ) ) !== $method ) {
+ if ( substr( (string) $key, - strlen( $method ) ) !== $method ) {

Eight characters, and the site went from dead to fully cached and serving in about two tenths of a second. Casting at the boundary is also exactly what the plugin authors will do when they ship their own fix, so this is not a hack so much as an early copy of the official answer.

One serious warning. A patch like this is wiped the next time the plugin updates. If you do this, write it down somewhere you will actually look, and check it after every update to that plugin until the vendor fix lands. An undocumented patch is a bug you have scheduled for later.

The real cause: a major update installed itself at 3am

Everything above is the mechanism. It is not the cause. The cause is that a major version of WordPress installed itself overnight, on a live site, with nobody watching and nothing tested first.

WordPress splits automatic updates into two kinds, and they are controlled separately:

  • Minor releases, which carry security and bug fixes. These auto-install by default, and you almost certainly want that.
  • Major releases, which carry new features and, occasionally, changes like the one in this story. These are opt-in.

On this site, major auto-updates had been switched on at some point by someone. You can check yours in a second:

$ wp option get auto_update_core_major
enabled

$ wp option get auto_update_core_minor
enabled

That first enabled is the reason this happened in the dark instead of on a Tuesday afternoon with someone watching. Turning it off takes one command, and importantly it leaves security updates running:

$ wp option update auto_update_core_major disabled
Success: Updated 'auto_update_core_major' option.

$ wp option get auto_update_core_major
disabled
$ wp option get auto_update_core_minor
enabled

If you would rather lock it at file level so it cannot be flipped back in the admin by accident, put this in wp-config.php instead:

define( 'WP_AUTO_UPDATE_CORE', 'minor' );

Be honest about what this buys you, though. It does not make the underlying incompatibility go away. The site will still break when someone eventually clicks the update button. What changes is that a human is awake, watching, and able to roll back, instead of finding out from an uptime alert at breakfast. Controlling when a risk lands is most of risk management.

Abstract illustration of an automated overnight cycle breaking, representing an unattended WordPress major auto-update.
A major version installing itself overnight, with nobody watching, is a decision about when you find out.

The security plugin was a red herring

One detail worth sharing, because it cost real worry. Alongside the outage, the site owner reported that their security plugin was showing far more blocked requests than normal, and reasonably asked whether the two things were connected. A dead site plus a spike in blocked traffic reads like an attack to anyone who has been through one before.

The access log settled it in about a minute:

$ grep -h -E 'wp-login|xmlrpc' access.log | \
    awk '{print $1}' | sort | uniq -c | sort -rn | head

     56 example.com
      4 www.example.com

The requests were not coming from outside at all. They were the site talking to itself: WordPress and the security plugin retrying their own loopback and AJAX calls against a server that was answering every one of them with a fatal error. Retry logic against a broken site produces a traffic pattern that looks a lot like a brute force attempt.

The blocked request spike was a symptom of the outage, not the cause of it. Worth remembering, because during an incident it is very easy to chase the scariest looking signal instead of the correct one.

How to check whether your own site is exposed

If you run a plugin that uses strict types, and you allow major core updates to install themselves, you have the same two ingredients. Here is a quick audit you can run per site:

# Is a major version going to install itself tonight?
$ wp option get auto_update_core_major

# Which plugins auto-update?
$ wp option get auto_update_plugins --format=json

# Which core and PHP version are you on?
$ wp core version
$ php -v | head -1

# Which of your plugins use strict types at all?
$ grep -rl 'declare(strict_types=1)' \
    wp-content/plugins/ --include='*.php' | \
    cut -d/ -f3 | sort -u

That last command is the interesting one. It tells you which of your plugins have opted into strict typing, which is the same as asking which of them will throw a hard error rather than absorbing an unexpected value. It is not a list of bad plugins. It is a list of the ones that will fail loudly the next time core changes something underneath them.

Then repeat it across every site you look after, because this class of failure does not visit one site. It visits every site running that combination, and it does it on the same night.

What we changed afterwards

Fixing the outage was the easy part. The changes that stop the next one are duller and matter more:

  • Major core auto-updates switched off, minor and security left on.
  • Major updates now go to staging first. The host offered staging the whole time and it was not being used for core releases.
  • The patch was written down, with the file, the line, the date, and a note to re-check it after every update to that plugin.
  • Monitoring now alerts on a 500 status, not only on the site being unreachable. A site returning error pages quickly is technically responding.
  • The same audit was run across the other sites in the portfolio running the same plugin, before any of them reached the new core version.

None of that is clever. It is the difference between finding out about a problem from your own checks and finding out from an angry client at eight in the morning.

The wider point, if you write plugins

It would be easy to read this and conclude that strict_types is a liability. It is not. It catches genuine bugs early, and the plugin here was doing something sensible.

The lesson is narrower than that: when a value comes from outside your own code, cast it at the boundary. Hook callback identifiers, option values, meta values, anything from a filter, anything from a third party. Inside a strict file, an assumption about a type is a promise the rest of the world never agreed to keep.

A single (string) at the point of use would have meant nobody ever noticed this core change happened.

Would you rather this never happened in the first place?

Most WordPress outages are preventable with unglamorous work: a sensible update policy, a staging site that actually gets used, monitoring that catches error pages, and someone who reads the alerts. We look after WordPress sites so their owners find out about problems from us rather than from their customers.

See how we look after sites

Frequently asked questions

Does a WordPress critical error mean I have been hacked?

Almost never. The overwhelming majority are a plugin, theme or PHP version incompatibility, and WordPress will usually name the culprit in an email to the site admin address. Check that email before assuming the worst. Be aware too that an outage can produce traffic patterns that look like an attack, because failed requests get retried, so a spike in blocked requests during downtime is often a symptom rather than a cause.

I cannot log in to wp-admin at all. How do I get back in?

Look for the WordPress fatal error email sent to the site admin address. It contains a recovery mode link that logs you in with the broken plugin paused. If that email never arrived, and you have SSH or WP-CLI access, use the –skip-plugins flag to deactivate the plugin without loading it. Failing both, renaming the plugin folder over SFTP forces WordPress to deactivate it.

Will a manual patch like this survive the next plugin update?

No. It will be overwritten the moment that plugin updates, and the site will break again in exactly the same way if the vendor has not yet shipped their own fix. Treat a manual patch as a temporary measure, record it somewhere visible, and check it after every update to that plugin until the official fix arrives.

Should I just turn off all WordPress automatic updates?

No. Minor releases carry security fixes and you want those applying themselves promptly. It is specifically major version auto-updates that deserve a human, a staging site and a working day. Setting WP_AUTO_UPDATE_CORE to ‘minor’ gives you exactly that split.

What if my host does not give me SSH or WP-CLI?

You can do the same recovery over SFTP. Rename the offending plugin’s folder inside wp-content/plugins, which forces WordPress to deactivate it on the next request, and rename any related drop-in file such as advanced-cache.php in wp-content. Once the site loads again you can log in normally and sort it out from the admin.

How do I work out which plugin caused the error in the first place?

In order: read the WordPress fatal error email, which usually names it outright. Then check your host’s PHP error log, which will carry the same stack trace. If neither is available, enable WP_DEBUG_LOG in wp-config.php and reproduce the error, then read the resulting debug.log. Guessing by deactivating plugins one at a time is the slowest possible method and should be the last resort.

Posted in 281

Leave a Reply

Your email address will not be published. Required fields are marked *