Обновлено 20 августа 2026
За одно утро мы подняли шесть сайтов с одной и той же аварией. Симптом одинаковый: обновили WordPress — и сайт лёг целиком. Не «поехала вёрстка», а именно белый экран или 500 на всём сразу: фронт, админка, корзина, формы, крон.
Виновник — WP Rocket. Точнее, его модуль интеграции с Cloudflare, который на PHP 8 спотыкается о новый способ хранения хуков в WordPress.
Ниже: как опознать за минуту, как починить за две, и почему это не разовая случайность, а мина, которая ждёт каждый сайт с этим плагином.
Если у вас включён вывод ошибок или вы заглянули в лог, вы увидите ровно это:
PHP Fatal error: Uncaught TypeError: substr(): Argument #1 ($string)
must be of type string, int given
in .../wp-content/plugins/wp-rocket/inc/ThirdParty/Plugins/CDN/Cloudflare.php:496
Дальше идёт трейс:
#0 .../CDN/Cloudflare.php(496): substr(6556, -24)
#1 .../CDN/Cloudflare.php(469): unregister_callback('deleted_post', 'purgeCacheByRel...')
#2 .../wp-includes/class-wp-hook.php(353): unregister_cloudflare_clean_on_post('')
...
#5 .../wp-settings.php(779): do_action('init')
Номер строки у вас может быть другим — 496 или 562, зависит от версии плагина. Число в substr(6556, -24) каждый раз новое: 6243, 7175, 8840. Это нормально, дальше объясню почему.
Если вывод ошибок выключен, вы увидите просто белый экран или «На сайте возникла критическая ошибка».
Три признака, все должны совпасть:
/wp-admin/.Плагин Cloudflare при этом может быть не установлен вовсе. Из шести наших сайтов он не стоял ни на одном. Это ключевой момент, к нему вернёмся.
grep -n 'substr( $key, - strlen( $method ) )' \
wp-content/plugins/wp-rocket/inc/ThirdParty/Plugins/CDN/Cloudflare.php
Строка нашлась — сайт под ударом. Ничего не выдало — либо WP Rocket другой версии, либо патч уже наложен.
Мы собрали плагин, который находит проблемную строку и правит её сам. Ставится обычным способом, без SSH и FTP.
WP Rocket PHP 8 Guard
версия 1.2 · 6 КБ · GPLv2
Установка: Плагины → Добавить новый → Загрузить плагин → выбрать ZIP → Установить → Активировать.
Патч накладывается сразу при активации. На странице «Плагины» появится строка состояния — зелёная «патч на месте» означает, что всё сделано.
Зачем он нужен постоянно, а не разово. Правка живёт в файле чужого плагина. При каждом обновлении WP Rocket файл перезаписывается и патч слетает — сайт ляжет снова, молча и в неудобный момент. Плагин проверяет файл после каждого обновления и накладывает патч заново.
Он безвреден там, где WP Rocket нет: первая же проверка — существует ли файл. Если нет, плагин просто ждёт.
Когда админка недоступна, залить плагин через неё не выйдет. Правим напрямую.
Файл:
wp-content/plugins/wp-rocket/inc/ThirdParty/Plugins/CDN/Cloudflare.php
Находим строку (496 или 562):
if ( substr( $key, - strlen( $method ) ) !== $method ) {
Меняем на:
if ( substr( (string) $key, - strlen( $method ) ) !== $method ) {
Всё изменение — приведение к строке. Девять символов.
Если правите руками, есть риск задеть не ту строку. Команда ниже сначала убеждается, что совпадение ровно одно, и только потом пишет:
F=wp-content/plugins/wp-rocket/inc/ThirdParty/Plugins/CDN/Cloudflare.php
perl -0777 -e '
my $f=$ARGV[0];
open(my $h,"<",$f) or die "OPEN_FAIL\n"; local $/; my $s=<$h>; close $h;
if ($s =~ /substr\(\s*\(string\)\s*\$key/) { print "ALREADY_PATCHED\n"; exit 0 }
my $re = qr/substr\(\s*\$key\s*,\s*-\s*strlen\(\s*\$method\s*\)\s*\)/;
my $n = () = ($s =~ /$re/g);
print "ANCHOR_COUNT=$n\n";
if ($n != 1) { print "ABORT\n"; exit 1 }
$s =~ s/$re/substr( (string) \$key, - strlen( \$method ) )/;
open(my $o,">",$f.".tmp") or die; print $o $s; close $o;
rename($f.".tmp",$f) or die;
print "PATCHED\n";
' "$F"
Ждём ANCHOR_COUNT=1 и PATCHED. Запись идёт через временный файл и rename, поэтому посетители не увидят полузаписанный файл.
После этого сайт поднимается сразу — перезапускать ничего не нужно. Дальше стоит поставить плагин из первого варианта, чтобы правка не слетела при следующем обновлении.
Разберём механику — она объясняет, почему ошибка выглядит случайной и почему её не лечит удаление Cloudflare.
WordPress держит все колбэки в глобальном массиве $wp_filter. Ключ для каждого строит функция _wp_filter_build_unique_id() в wp-includes/plugin.php:
if ( is_object( $callback ) ) {
return (string) spl_object_id( $callback );
}
Для колбэка-замыкания ключ получается чистым числом в виде строки: "6556".
А дальше вступает многолетнее правило PHP: при записи в массив числовая строка автоматически превращается в int. Строка "6556" становится числом 6556.
Именно поэтому число в трейсе каждый раз разное — spl_object_id выдаёт новый идентификатор при каждом запросе. Ошибка выглядит плавающей, хотя на деле совершенно детерминирована.
Файл Cloudflare.php объявлен со строгой типизацией — declare(strict_types=1). Метод перебирает колбэки и передаёт ключ в substr():
foreach ( $original_wp_filter[ $priority ] as $key => $config ) {
if ( substr( $key, - strlen( $method ) ) !== $method ) {
На PHP 7 число молча привелось бы к строке. На PHP 8 со строгой типизацией это фатальная ошибка.
Самое неочевидное. В классе десяток методов, и почти все начинаются с проверки:
if ( ! $this->is_plugin_active() ) {
return;
}
Метод unregister_cloudflare_clean_on_post() — единственный без этой проверки. При этом он подписан на хук init, то есть выполняется на каждом запросе к сайту.
Отсюда и картина: плагин Cloudflare не установлен, учётные данные не заведены, CDN не используется — а сайт всё равно ложится.
init — один из самых ранних хуков WordPress, срабатывает в wp-settings.php до того, как система решит, куда направить запрос. Поэтому умирает не какая-то страница, а любой вход в WordPress: фронт, /wp-admin/, admin-ajax.php (то есть все формы и корзина), wp-cron.php.
Отдельно стоит предупредить. На одном из наших сайтов главная отдавала честные 200, пока админка уже возвращала 500.
Причина в том, что закешированные страницы WP Rocket отдаёт мимо PHP — прямо из статики. Пока кеш не протух, посетители видят рабочий сайт.
Что при этом происходит на самом деле:
admin-ajax.php);wp-cron не работает: письма не уходят, задачи не выполняются, товары не выгружаются.Так что «главная открывается» — не повод расслабиться. Проверяйте админку.
Мы наблюдали падение на этих версиях WP Rocket:
| Версия WP Rocket | Строка |
|---|---|
| 3.18.2 | 496 |
| 3.20.1.2 | 496 |
| 3.21.3 | 562 |
| 3.23.1.1 | 562 |
| 3.23.2.1 | 562 |
Диапазон широкий, свежие версии в списке есть. То есть обновление WP Rocket проблему не решает — по крайней мере на момент публикации. Ждать апдейта, сидя с упавшим сайтом, смысла нет.
Подтверждённая связка — WordPress 7.1. На сайтах с 7.0.4 уязвимый код в файле присутствует, но падений мы не видели: похоже, там ещё не сложились условия. Считать их безопасными не стоит — при обновлении ядра ляжет и они.
Самый дешёвый сценарий: поставить плагин до обновления WordPress, а не после.
Обновление пройдёт незаметно вместо аварийного вечера. Если у вас несколько сайтов, разложить плагин заранее гораздо дешевле, чем поднимать их по одному по мере падения — тем более что часть из них будет выглядеть живой за счёт кеша.
Когда WP Rocket выпустит версию с исправлением. Плагин заметит это сам: если знакомой строки в коде больше нет, а патча тоже нет, он покажет сообщение, что вендор всё починил и сторожа можно отключить.
При деактивации патч не откатывается — иначе сайт немедленно ляжет.
substr() при строгой типизации.substr( (string) $key, ... ).WP Rocket PHP 8 Guard
версия 1.2 · 6 КБ · GPLv2
Можно просто отключить WP Rocket?
Да, сайт поднимется. Но вы потеряете кеширование, и скорость сайта просядет — иногда в разы. Патч решает вопрос без этой платы.
А если удалить плагин Cloudflare?
Не поможет. Проблемный метод — единственный в классе без проверки на активность Cloudflare. Он выполняется независимо от того, установлен плагин или нет.
Не сломает ли патч логику WP Rocket?
Нет. Настоящие колбэки WP Rocket имеют ключ вида <идентификатор><имя_метода> — они по-прежнему находятся и обрабатываются как задумано. Изменение касается только числовых ключей чужих замыканий, которые и раньше не должны были совпадать.
Почему число в ошибке каждый раз разное?
Это идентификатор объекта в памяти, spl_object_id. Он выдаётся заново при каждом запросе. Ошибка выглядит случайной, но воспроизводится стопроцентно.
У меня PHP 7, я в безопасности?
На PHP 7 число молча приводится к строке, фатала не будет. Но PHP 7 давно снят с поддержки, и оставаться на нём ради обхода одного бага — плохая сделка.
Столкнулись с этим на нескольких сайтах и не хотите разбираться вручную — напишите нам, поможем разложить решение по всему парку.