Система хуків 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+).
Взаємодію компонентів системи хуків слід розглядати як детермінований дворівневий конвеєр: фазу статичної реєстрації в хеш-таблицю та фазу динамічної диспетчеризації зі стековим розгортанням.
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) ───── Сортування пріоритетів (якщо активний dirty-прапор)
├── 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].
Групування обробників за цілочисельним ключем priority. Скидання кешу сортування всередині екземпляра WP_Hook.
Додавання тегу в $wp_current_filter, фіксація вказівника ітератора у WP_Hook::$iterations для підтримки вкладеності.
Послідовний виклик колбеків через call_user_func_array з розпаковкою аргументів. Передача трансформованого значення між кроками фільтра.
Зняття поточного вказівника з WP_Hook::$iterations, вилучення тегу з $wp_current_filter, повернення фінального значення контексту виклику.
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 тестування та стану фіче-тоглів без модифікації базового коду.Принципове розуміння різниці між 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();У чому суть помилки: Розробник пише колбек фільтра для логування, відправлення повідомлень або часткової валідації і забуває вказати 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);
}
});Колбек передається у функцію 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.