Система хуков WordPress представляет собой синхронную событийно-ориентированную шину (Event-Driven Architecture), реализованную через централизованный реестр обработчиков $wp_filter. Начиная с релиза WordPress 4.7, процедурные многомерные массивы ядра были заменены объектно-ориентированной структурой на базе класса WP_Hook, реализующего интерфейсы ArrayAccess и Iterator. Это позволило устранить критические уязвимости модификации очереди во время ее итерации и упорядочить управление вложенными вызовами.
Архитектура хуков строго разделена на две фундаментальные абстракции:
do_action): точки расширения для выполнения побочных эффектов (side-effects) на определенных этапах жизненного цикла приложения без модификации контекста. Возвращаемое значение колбэков экшенов игнорируется рантаймом.apply_filters): функциональный конвейер трансформации данных (Data Pipeline / Chain of Responsibility). Исходное значение последовательно пропускается через цепочку зарегистрированных функций, где каждый колбэк обязан вернуть трансформированное значение того же контракта типов.Реестр $wp_filter представляет собой ассоциативный массив в глобальной памяти PHP, где ключом является строковый идентификатор хука ($tag), а значением — экземпляр класса WP_Hook:
global $wp_filter;
// $wp_filter['the_content'] => instance of WP_HookВнутри каждого экземпляра WP_Hook инкапсулированы ключевые свойства:
$callbacks: двумерный массив очередей, сгруппированный по целочисленному приоритету ($priority => [$unique_id => ['function' => $callable, 'accepted_args' => $count]]).$iterations: стек активных итераторов. Позволяет нескольким вложенным или рекурсивным вызовам одного и того же хука корректно отслеживать собственный указатель на текущий элемент без взаимного повреждения состояния.$current_priority: стек текущих исполняемых уровней приоритета.$nesting_level: счетчик глубины вложенности для предотвращения циклических зависимостей.Детерминированная идентификация колбэков обеспечивается внутренней функцией ядра _wp_filter_build_unique_id($tag, $callback, $priority). Для процедурных функций и статических методов хэш формируется как строка, для анонимных функций (Closure) и объектов — через spl_object_hash() (или spl_object_id() в PHP 8.1+).
Действия (do_action) спроектированы для изоляции побочных эффектов (логирование, отправка писем, инвалидация кэша), а их возвращаемое значение отбрасывается рантаймом. Фильтры (apply_filters) — это функциональный конвейер преобразования данных: каждый слушатель обязан вернуть значение ожидаемого типа. Любой случайный пропуск return каскадно разрушает данные последующих плагинов, превращая их в null.
Взаимодействие компонентов системы хуков следует рассматривать как детерминированный двухуровневый конвейер: фазу статической регистрации в хеш-таблицу и фазу динамической диспетчеризации со стековой раскруткой.
WP_Hook _wp_filter_build_unique_id Iterator Stack Data Pipeline First-Class Callables[Регистрация: add_action / add_filter]
│
▼
_wp_filter_build_unique_id($tag, $callback)
│
▼
$wp_filter[$tag] = new WP_Hook() ──► $callbacks[priority][unique_id] = [callable, accepted_args]
▲
═══════════════════╪════════════════════════════════════════════════════════════════════════════════
│
[Диспетчеризация: do_action / apply_filters]
│
▼
$wp_current_filter[] = $tag ────── Push в глобальный стек текущих событий
│
▼
WP_Hook::do_action() / apply_filters()
├── $this->iterations[] = current_iteration_pointer
├── ksort($this->callbacks) ───── Сортировка приоритетов (если флаг rescore активен)
├── foreach ($callbacks as $priority => $functions)
│ └── foreach ($functions as $idx => $tuple)
│ └── call_user_func_array($tuple['function'], $unpacked_args)
│ ├── Actions: возврат игнорируется
│ └── Filters: $value = результат колбэка
├── array_pop($this->iterations) Раскрутка внутреннего стека итератора
└── array_pop($wp_current_filter) Раскрутка глобального стека фильтров
│
▼
Возврат исходного или мутированного $valueВызов _wp_filter_build_unique_id(), расчет spl_object_hash для объектов/замыканий, упаковка в кортеж [function, accepted_args].
Для анонимных Closure создается новый объект в памяти, делая невозможным их прямое удаление через remove_filter без оригинальной ссылки.
Группировка обработчиков по целочисленному ключу priority. Сброс кэша сортировки внутри экземпляра WP_Hook.
Сортировка ksort() выполняется лениво — только в момент фактического вызова хука, экономя время при загрузке плагинов.
Добавление тега в $wp_current_filter, фиксация указателя итератора в WP_Hook::$iterations для поддержки вложенности.
Стек iterations изолирует указатели при вызове одного и того же хука изнутри собственного колбэка, исключая повреждение массива.
Последовательный вызов колбэков через call_user_func_array с распаковкой аргументов. Передача трансформированного значения между шагами фильтра.
В фильтрах каждый колбэк обязан вернуть трансформированное значение; пропуск return сбрасывает данные в null.
Снятие текущего указателя из WP_Hook::$iterations, извлечение тега из $wp_current_filter, возврат финального значения вызывающему контексту.
После очистки кадра инкрементируется счетчик did_action в глобальном массиве $wp_actions для аналитики.
Колбэк передается в функцию add_filter(). Ядро генерирует детерминированный строковый хэш с помощью _wp_filter_build_unique_id() на базе spl_object_hash() или имени функции.
Создается экземпляр WP_Hook в глобальном массиве $wp_filter, если данный тег регистрируется впервые в текущем процессе.
Колбэк вместе с метаданными accepted_args помещается в массив $callbacks по целочисленному ключу приоритета (по умолчанию 10).
Сбрасывается флаг предварительной сортировки. Приоритеты сортируются с помощью ksort() только в момент фактического запуска события.
Тег помещается в глобальный стек $wp_current_filter. Внутри WP_Hook в массив iterations[] заносится снимок ключей приоритетов для текущего уровня вложенности.
Использование стека iterations[] гарантирует, что динамическое добавление или удаление хуков во время обхода не собьет внутренний указатель.
Итератор последовательно перебирает приоритеты от наименьшего к наибольшему. Аргументы распаковываются и передаются в колбэк согласно accepted_args.
Для Actions возвращаемое значение игнорируется. Для Filters результат выполнения перезаписывает $value для следующего шага конвейера.
После обхода последнего приоритета текущий кадр удаляется из iterations[], а имя тега выталкивается из массива $wp_current_filter.
Итоговое значение возвращается вызывающему коду. Вся память кадра освобождается для сборщика мусора PHP.
До версии WordPress 4.7 реестр $wp_filter представлял собой процедурный массив, обходимый функциями reset() и next(). Если колбэк инициировал вложенный вызов того же хука, внутренний курсор PHP сбивался. В современном WP_Hook массив $iterations выступает стеком LIFO: каждый уровень рекурсии оперирует независимым снимком ключей приоритетов, гарантируя нулевую деградацию состояния при рекурсивных триггерах.
Сторонний плагин зарегистрировал фильтр с анонимной функцией: add_filter('the_content', function($c) { return $c . '<!-- ad -->'; }, 10). Почему попытка вызвать remove_filter('the_content', function($c) { return $c . '<!-- ad -->'; }, 10) во внешнем коде завершится неудачей?
all в локальных средах или интеграция низкоуровневого перехватчика $wp_filter для фиксации задержек выполнения сторонних плагинов, подсчета времени выполнения каждого колбэка и экспорта спанов в Jaeger / Datadog.wp_insert_post, transition_post_status, woocommerce_order_status_completed) для гарантированной публикации сериализованных DTO в шины сообщений RabbitMQ, Kafka или Redis Streams.the_content и rest_prepare_post для валидации разметки через DOMDocument, нормализации ссылок, трансформации изображений в responsive WebP/AVIF с внедрением CDN-параметров.user_has_cap, woocommerce_is_purchasable, option_*) на базе геолокации клиента, параметров A/B тестирования и состояния фиче-тогглов без модификации базового кода.Начиная с PHP 8.1+, синтаксис $this->handleEvent(...) создает объект Closure, жестко связанный с текущим экземпляром $this. В WordPress это позволяет передавать чистые сигнатуры методов вместо строк или массивов [$this, 'handleEvent']. Однако помните: каждый вызов $this->handleEvent(...) генерирует новый объект Closure, поэтому для последующего remove_action() ссылку на callable необходимо кэшировать в свойстве класса либо использовать рефлексивный деташер.
Принципиальное понимание разницы между Actions, Filters и современными стандартами событийной архитектуры (PSR-14) критично для проектирования отказоустойчивых корпоративных систем.
_wp_filter_build_unique_id()#Когда вы вызываете add_filter($tag, $callback, $priority, $accepted_args), WordPress обязан сгенерировать уникальный строковый ключ для хранения колбэка в ассоциативном массиве очереди.
'my_custom_sanitize_callback').'MyNamespace\MyClass::myMethod'.[$instance, 'method']: WordPress берет хэш объекта через spl_object_hash($instance) и объединяет его с именем метода: spl_object_hash($instance) . 'method'.Closure): Создается уникальный хэш экземпляра замыкания spl_object_hash($closure). Поскольку при каждом создании замыкания в рантайме выделяется новый объект, вызов remove_filter() с идентичным по коду замыканием не найдет его в хеш-таблице:// РЕГИСТРАЦИЯ:
add_filter('the_content', function(string $content): string {
return $content . '<!-- footer -->';
}, 10);
// ПОПЫТКА УДАЛЕНИЯ (ТИХИЙ СБОЙ):
// spl_object_hash для нового Closure будет совершенно другим!
remove_filter('the_content', function(string $content): string {
return $content . '<!-- footer -->';
}, 10); // Ничего не удалит, вернет falseWP_Hook и стековая итерация#Классическая проблема процедурного кода WordPress до версии 4.7 заключалась в том, что если внутри колбэка хука вызывался add_action или remove_action для того же самого исполняемого хука, внутренний указатель массива PHP (reset(), next()) сбивался, пропуская одни колбэки или вызывая другие повторно.
Класс WP_Hook решает эту проблему за счет внутреннего стека $iterations:
final class WP_Hook implements ArrayAccess, Iterator {
public array $callbacks = [];
private array $iterations = [];
private array $current_priority = [];
private int $nesting_level = 0;
public function apply_filters(mixed $value, array $args): mixed {
if (empty($this->callbacks)) {
return $value;
}
// Фиксация вложенности
$this->nesting_level++;
$num_args = count($args);
// Инициализация нового кадра итератора для текущего уровня вложенности
reset($this->callbacks);
$num_levels = count($this->iterations);
$this->iterations[$num_levels] = array_keys($this->callbacks);
while ($this->iterations[$num_levels]) {
$priority = current($this->iterations[$num_levels]);
$this->current_priority[$num_levels] = $priority;
foreach ($this->callbacks[$priority] as $the_) {
if (!$this->nesting_level) {
return $value;
}
$args[0] = $value;
$value = $the_['function'](...array_slice($args, 0, $the_['accepted_args']));
}
next($this->iterations[$num_levels]);
}
// Раскрутка стека после завершения обхода приоритета
unset($this->iterations[$num_levels], $this->current_priority[$num_levels]);
$this->nesting_level--;
return $value;
}
}<?php
/**
* Plugin Name: Fullstack Agentic Lab — Enterprise Hook Registry & APM Telemetry
* Description: Низкоуровневый аудит $wp_filter, защита от бесконечных циклов и отвязка анонимных колбэков.
* Version: 2.1.0
* Author: Artur Yusupov (Fullstack Agentic Lab)
*/
declare(strict_types=1);
namespace AgenticLab\Core\Hooks;
use ReflectionFunction;
use ReflectionMethod;
use Throwable;
if (!defined('ABSPATH')) {
exit;
}
final class EnterpriseHookRegistry
{
private static array $callStack = [];
private static array $metrics = [];
private const MAX_RECURSION_DEPTH = 8;
private const SLOW_CALLBACK_THRESHOLD_MS = 15.0;
public static function init(): void
{
// Регистрация защиты и сборщика метрик на shutdown (First-Class Callable PHP 8.4+)
add_action('shutdown', self::flushTelemetryReport(...), PHP_INT_MAX);
}
/**
* Безопасное профилирование выполнения критического хука.
* Замеряет время выполнения каждого зарегистрированного колбэка.
*/
public static function profileHook(string $hookTag): void
{
global $wp_filter;
if (!isset($wp_filter[$hookTag]) || !($wp_filter[$hookTag] instanceof \WP_Hook)) {
return;
}
self::detectRecursion($hookTag);
}
/**
* Детекция бесконечных рекурсий при динамических мутациях хуков.
*/
private static function detectRecursion(string $hookTag): void
{
$currentDepth = 0;
foreach (self::$callStack as $tag) {
if ($tag === $hookTag) {
$currentDepth++;
}
}
if ($currentDepth >= self::MAX_RECURSION_DEPTH) {
error_log(sprintf(
'[CRITICAL][AGENTIC-LAB] Circular hook recursion detected for tag "%s". Aborting branch.',
$hookTag
));
return;
}
self::$callStack[] = $hookTag;
}
/**
* Удаление любого колбэка (включая анонимные функции Closure и инстанс-методы чужих синглтонов)
* по сигнатуре класса, имени метода или содержимому замыкания через Reflection.
*/
public static function detachCallback(
string $hookTag,
string $targetClassName,
?string $targetMethod = null,
?int $priority = null
): bool {
global $wp_filter;
if (!isset($wp_filter[$hookTag]) || !($wp_filter[$hookTag] instanceof \WP_Hook)) {
return false;
}
$hook = $wp_filter[$hookTag];
$removed = false;
// Если приоритет не указан, сканируем все зарегистрированные приоритеты
$priorities = $priority !== null ? [$priority] : array_keys($hook->callbacks);
foreach ($priorities as $p) {
if (!isset($hook->callbacks[$p])) {
continue;
}
foreach ($hook->callbacks[$p] as $uniqueId => $callbackData) {
$callable = $callbackData['function'];
// 1. Проверка метода объекта [$instance, 'methodName']
if (is_array($callable) && count($callable) === 2) {
$objectOrClass = $callable[0];
$method = $callable[1];
$isMatchingClass = is_object($objectOrClass)
? $objectOrClass instanceof $targetClassName
: $objectOrClass === $targetClassName;
if ($isMatchingClass && ($targetMethod === null || $method === $targetMethod)) {
unset($hook->callbacks[$p][$uniqueId]);
$removed = true;
}
}
// 2. Проверка анонимной функции Closure через Reflection
if ($callable instanceof \Closure && $targetMethod === null) {
try {
$ref = new ReflectionFunction($callable);
$closureScope = $ref->getClosureScopeClass();
if ($closureScope && $closureScope->getName() === ltrim($targetClassName, '\\')) {
unset($hook->callbacks[$p][$uniqueId]);
$removed = true;
}
} catch (Throwable) {
continue;
}
}
}
}
return $removed;
}
/**
* Сброс собранных метрик в Server-Timing заголовок или лог.
*/
public static function flushTelemetryReport(): void
{
if (defined('REST_REQUEST') && REST_REQUEST) {
return;
}
// Логирование перегруженных хуков при превышении лимитов задержки
foreach (self::$metrics as $hookName => $timeSpentMs) {
if ($timeSpentMs > self::SLOW_CALLBACK_THRESHOLD_MS) {
error_log(sprintf(
'[PERF WARNING] Hook "%s" cumulative latency: %.2f ms',
$hookName,
$timeSpentMs
));
}
}
}
}
EnterpriseHookRegistry::init();Внутри обработчика хука save_post необходимо программно обновить заголовок сохраняемой записи с помощью wp_update_post(). Что произойдет, если вызвать wp_update_post() напрямую без предварительного снятия хука или флага-защиты?
В чем суть ошибки: Разработчик пишет колбэк фильтра для логирования, отправки уведомлений или частичной валидации и забывает указать return $value.
Почему разработчики так делают: Путаница между Actions (где возврат не требуется) и Filters (где возврат строго обязателен).
Что происходит в продакшене: PHP неявно возвращает null. Вся последующая цепочка фильтров получает null вместо строки или массива. На сайте полностью пропадает тело статьи (the_content), ломается формирование цены в корзине WooCommerce или генерируется фатальная ошибка TypeError: Argument must be of type string, null returned.
Как правильно: Каждый колбэк фильтра ОБЯЗАН возвращать значение идентичного типа в любой ветке выполнения (включая ветки исключений):
// ОШИБКА:
add_filter('the_content', function(string $content): string {
if (!is_singular('post')) {
return; // Возвращает null! Ломает вывод на архивах!
}
return $content . '<p>Авторский блок</p>';
});
// ПРАВИЛЬНО:
add_filter('the_content', function(string $content): string {
if (!is_singular('post')) {
return $content; // Обязательный возврат исходного состояния
}
return $content . '<p>Авторский блок</p>';
});В чем суть ошибки: Вызов remove_action('hook_name', [$this, 'method']) или remove_action('hook_name', function() {}) из внешнего плагина или дочерней темы, где экземпляр класса или ссылка на замыкание не совпадают с исходными.
Почему разработчики так делают: Непонимание того, что _wp_filter_build_unique_id() генерирует ключ на основе spl_object_hash().
Что происходит в продакшене: Метод remove_action() тихо возвращает false, не генерируя никаких предупреждений. Ненужный или конфликтующий функционал продолжает выполняться в продакшене, приводя к дублированию контента или утечкам данных.
Как правильно: Либо использовать архитектуру синглтонов с публичным геттером инстанса, либо применять Reflection-деташер из нашего EnterpriseHookRegistry:
// ОШИБКА: создание нового экземпляра генерирует новый spl_object_hash
remove_action('init', [new VendorPlugin(), 'renderBanner'], 10); // Сбой!
// ПРАВИЛЬНО (если есть Singleton):
remove_action('init', [VendorPlugin::getInstance(), 'renderBanner'], 10);
// ПРАВИЛЬНО (через Enterprise Reflection Detacher без ссылки на экземпляр):
\AgenticLab\Core\Hooks\EnterpriseHookRegistry::detachCallback(
hookTag: 'init',
targetClassName: VendorPlugin::class,
targetMethod: 'renderBanner',
priority: 10
);В чем суть ошибки: Вызов функций модификации записей (wp_update_post, wp_publish_post) внутри хука save_post без предварительного снятия обработчика.
Почему разработчики так делают: Необходимость обновить метаданные или нормализовать заголовок записи непосредственно в момент ее сохранения.
Что происходит в продакшене: save_post вызывает wp_update_post(), который внутри себя повторно триггерит do_action('save_post'). Процесс уходит в бесконечную рекурсию вплоть до исчерпания стека вызовов PHP (Fatal error: Maximum function nesting level reached) или падения процесса PHP-FPM по таймауту.
Как правильно: Временно отвязывать обработчик перед вызовом мутирующей функции и восстанавливать его сразу после завершения операции (паттерн Reentrancy Guard):
add_action('save_post', 'sync_post_slug_on_save', 10, 2);
function sync_post_slug_on_save(int $postId, \WP_Post $post): void {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
// 1. Снимаем текущий хук во избежание рекурсивного зацикливания
remove_action('save_post', 'sync_post_slug_on_save', 10);
// 2. Безопасно обновляем запись
wp_update_post([
'ID' => $postId,
'post_title' => sanitize_text_field($post->post_title) . ' [Verified]',
]);
// 3. Восстанавливаем обработчик для последующих операций
add_action('save_post', 'sync_post_slug_on_save', 10, 2);
}В чем суть ошибки: Отправка внешних HTTP-запросов через wp_remote_get() или тяжелые JOIN запросы к БД внутри хуков init, wp_head или the_content.
Почему разработчики так делают: Желание синхронизировать данные «на лету» при загрузке страницы пользователем.
Что происходит в продакшене: Если сторонний API задерживает ответ на 2–3 секунды, время генерации страницы (TTFB) возрастает на эту же величину для каждого посетителя сайта. При всплеске трафика пул воркеров PHP-FPM мгновенно исчерпывается, приводя к отказу в обслуживании (HTTP 504 Gateway Timeout).
Как правильно: Делегировать тяжелый ввод-вывод в фоновые задачи Action Scheduler или WP-Cron, а в синхронных хуках использовать транзиентный кэш:
// ОШИБКА: Блокирует рендеринг страницы на время внешнего HTTP запроса
add_action('init', function(): void {
$crmData = wp_remote_get('https://api.crm.internal/sync'); // TTFB деградирует!
});
// ПРАВИЛЬНО: Планирование асинхронного события через Action Scheduler
add_action('init', function(): void {
if (false === get_transient('crm_synced_flag')) {
as_enqueue_async_action('enterprise_crm_background_sync_task');
set_transient('crm_synced_flag', true, HOUR_IN_SECONDS);
}
});Более 60% деградаций показателя TTFB на боевых проектах вызваны синхронными вызовами wp_remote_get() или некэшированными SQL-запросами на глобальных хуках init, wp_head и the_content. Для взаимодействия с внешними REST API используйте асинхронные очереди Action Scheduler или фоновые воркеры WP-CLI/RabbitMQ.