El sistema de hooks de WordPress es un bus síncrono dirigido por eventos (Event-Driven Architecture) implementado mediante un registro centralizado de controladores en memoria: $wp_filter. A partir de WordPress 4.7, los arrays PHP multidimensionales de carácter procedural fueron reemplazados por una arquitectura orientada a objetos sustentada en la clase WP_Hook, que implementa las interfaces ArrayAccess e Iterator. Esto permitió resolver fallos críticos al modificar la cola en tiempo de ejecución y estructurar el control de invocaciones recursivas y anidadas.
La arquitectura de hooks se divide en dos abstracciones fundamentales:
do_action): Puntos de extensión para ejecutar efectos secundarios (side-effects) en etapas específicas del ciclo de vida de la aplicación sin alterar el contexto. El valor retornado por los callbacks es ignorado por el runtime.apply_filters): Un pipeline funcional de transformación de datos (Chain of Responsibility). Un valor inicial se procesa secuencialmente a través de una cadena de funciones registradas, donde cada callback debe retornar el valor transformado respetando el mismo contrato de tipos.El registro $wp_filter es un array asociativo en la memoria global de PHP, donde cada clave es un identificador de evento ($tag) y su valor es una instancia de WP_Hook:
global $wp_filter;
// $wp_filter['the_content'] => instance of WP_HookDentro de cada instancia de WP_Hook se encapsulan propiedades esenciales:
$callbacks: Un array bidimensional indexado por prioridad entera ($priority => [$unique_id => ['function' => $callable, 'accepted_args' => $count]]).$iterations: La pila de iteradores activos. Permite que múltiples invocaciones anidadas o recursivas sobre el mismo hook mantengan sus punteros independientes sin corromper el estado.$current_priority: Pila que registra los niveles de prioridad en ejecución activa.$nesting_level: Contador de profundidad de anidamiento para prevenir bucles infinitos.La identificación determinista de callbacks se efectúa mediante la función interna _wp_filter_build_unique_id($tag, $callback, $priority). Para funciones y métodos estáticos se genera una cadena de texto; para clausuras (Closure) e instancias de objetos se obtiene mediante spl_object_hash() (o spl_object_id() en PHP 8.1+).
La interacción de los componentes del sistema de hooks debe concebirse como un pipeline determinista de dos niveles: fase de registro estático en la tabla hash seguida de la fase de despacho dinámico con desenrollado de pila.
WP_Hook _wp_filter_build_unique_id Iterator Stack Data Pipeline First-Class Callables[Registro: add_action / add_filter]
│
▼
_wp_filter_build_unique_id($tag, $callback)
│
▼
$wp_filter[$tag] = new WP_Hook() ──► $callbacks[priority][unique_id] = [callable, accepted_args]
▲
═══════════════════╪════════════════════════════════════════════════════════════════════════════════
│
[Despacho: do_action / apply_filters]
│
▼
$wp_current_filter[] = $tag ────── Push a la pila global de eventos activos
│
▼
WP_Hook::do_action() / apply_filters()
├── $this->iterations[] = current_iteration_pointer
├── ksort($this->callbacks) ───── Ordena prioridades si la caché está sucia
├── foreach ($callbacks as $priority => $functions)
│ └── foreach ($functions as $idx => $tuple)
│ └── call_user_func_array($tuple['function'], $unpacked_args)
│ ├── Actions: retorno ignorado
│ └── Filters: $value = resultado del callback
├── array_pop($this->iterations) Desapila el iterador interno
└── array_pop($wp_current_filter) Desapila el filtro global
│
▼
Retorno del $value original o transformadoLlamada a _wp_filter_build_unique_id(), cálculo de spl_object_hash para objetos/clausuras, empaquetado en tupla [function, accepted_args].
Agrupación de callbacks bajo la clave entera de prioridad. Marca la ordenación como pendiente en WP_Hook.
Añade el tag a $wp_current_filter, almacena el cursor en WP_Hook::$iterations para permitir recursión.
Ejecución secuencial mediante call_user_func_array con argumentos desempaquetados. Transmisión del dato transformado entre filtros.
Extracción del cursor de WP_Hook::$iterations, remoción del tag de $wp_current_filter, entrega del valor final al llamador.
all en entornos locales o interceptación de $wp_filter para registrar la latencia de plugins de terceros, medir el tiempo por callback y exportar trazas a Jaeger o Datadog.wp_insert_post, transition_post_status, woocommerce_order_status_completed) para publicar mensajes DTO serializados de forma fiable hacia RabbitMQ, Kafka o Redis Streams.the_content y rest_prepare_post para validar HTML con DOMDocument, normalizar URLs y reescribir imágenes en formatos responsivos WebP/AVIF con URLs de CDN.user_has_cap, woocommerce_is_purchasable, option_*) según geolocalización, buckets A/B y feature toggles sin alterar código base.Comprender la diferencia arquitectónica entre los hooks de WordPress y los estándares modernos de despacho de eventos (como PSR-14) es fundamental para diseñar sistemas empresariales robustos.
_wp_filter_build_unique_id()#Al registrar mediante add_filter($tag, $callback, $priority, $accepted_args), WordPress crea una clave en string única:
'my_custom_sanitize_callback').'MyNamespace\MyClass::myMethod'.[$instance, 'method']: WordPress toma el hash del objeto mediante spl_object_hash($instance) y lo concatena con el método: spl_object_hash($instance) . 'method'.Closure): Se genera un hash único para la instancia de clausura spl_object_hash($closure). Dado que cada creación de clausura instancia un nuevo objeto en memoria, intentar usar remove_filter() con una clausura idéntica en código fallará silenciosamente:// REGISTRO:
add_filter('the_content', function(string $content): string {
return $content . '<!-- footer -->';
}, 10);
// INTENTO DE REMOCIÓN (FALLO SILENCIOSO):
// ¡El spl_object_hash para esta nueva Closure será diferente!
remove_filter('the_content', function(string $content): string {
return $content . '<!-- footer -->';
}, 10); // Retorna false y no elimina nadaWP_Hook e iteración apilada#El problema histórico del código procedural de WordPress anterior a la 4.7 era que invocar add_action o remove_action dentro de un callback en ejecución alteraba los punteros internos de arrays en PHP (reset(), next()), saltándose callbacks o generando bucles infinitos.
WP_Hook resuelve esto con su pila interna $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);
// Nuevo marco de iterador para el nivel de anidamiento actual
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]);
}
// Desenlace de la pila al concluir las prioridades
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: Auditoría de bajo nivel de $wp_filter, control de recursión y desvinculación de callbacks anónimos.
* 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
{
// Registro de telemetría en shutdown (First-Class Callable PHP 8.4+)
add_action('shutdown', self::flushTelemetryReport(...), PHP_INT_MAX);
}
/**
* Perfilado seguro de ejecución de hooks y detección de recursión.
*/
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);
}
/**
* Control de bucles infinitos en cadenas dinámicas de hooks.
*/
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;
}
/**
* Elimina cualquier callback (incluyendo Closures y métodos privados de singletons ajenos)
* por firma de clase, nombre de método o alcance de clausura vía 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. Métodos de instancia [$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. Clausuras anónimas vía 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;
}
/**
* Vuelca métricas recolectadas a cabecera Server-Timing o registro de errores.
*/
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();En qué consiste el error: Escribir un callback en un filtro para auditoría, notificaciones o validación y olvidar incluir return $value.
Por qué lo hacen los desarrolladores: Confusión entre Actions (donde no se requiere retorno) y Filters (donde el retorno es obligatorio).
Qué ocurre en producción: PHP retorna null implícitamente. Todos los filtros subsiguientes reciben null en lugar de una cadena o array. Desaparece el contenido de la publicación (the_content), fallan los cálculos de precios en WooCommerce o se generan excepciones fatales TypeError: Argument must be of type string, null returned.
Forma correcta: Todo callback de filtro DEBE retornar un valor del tipo adecuado en todas las ramas de ejecución:
// ERROR:
add_filter('the_content', function(string $content): string {
if (!is_singular('post')) {
return; // ¡Retorna null! ¡Rompe los archivos!
}
return $content . '<p>Bloque de Autor</p>';
});
// CORRECTO:
add_filter('the_content', function(string $content): string {
if (!is_singular('post')) {
return $content; // Retorna siempre el estado recibido
}
return $content . '<p>Bloque de Autor</p>';
});En qué consiste el error: Invocar remove_action('hook_name', [$this, 'method']) o remove_action('hook_name', function() {}) desde un tema hijo o plugin sin la referencia original.
Por qué lo hacen los desarrolladores: Desconocimiento de que _wp_filter_build_unique_id() genera identificadores basados en spl_object_hash().
Qué ocurre en producción: remove_action() retorna false silenciosamente. El código indeseado sigue ejecutándose en producción provocando duplicaciones de datos o filtraciones.
Forma correcta: Emplear patrones singleton con accesores públicos o usar el extractor por Reflection de EnterpriseHookRegistry:
// ERROR: Instanciar un nuevo objeto crea un spl_object_hash diferente
remove_action('init', [new VendorPlugin(), 'renderBanner'], 10); // ¡Falla silenciosamente!
// CORRECTO (Referencia singleton):
remove_action('init', [VendorPlugin::getInstance(), 'renderBanner'], 10);
// CORRECTO (Vía Reflection sin necesidad de la instancia original):
\AgenticLab\Core\Hooks\EnterpriseHookRegistry::detachCallback(
hookTag: 'init',
targetClassName: VendorPlugin::class,
targetMethod: 'renderBanner',
priority: 10
);En qué consiste el error: Llamar a funciones de actualización (wp_update_post, wp_publish_post) dentro del hook save_post sin desvincular previamente el callback.
Por qué lo hacen los desarrolladores: Necesidad de actualizar metadatos o normalizar el título en el momento exacto del guardado.
Qué ocurre en producción: save_post ejecuta wp_update_post(), que a su vez dispara de nuevo do_action('save_post'). El proceso entra en recursión infinita hasta agotar la pila de llamadas de PHP (Fatal error: Maximum function nesting level reached) o agotar el tiempo de PHP-FPM.
Forma correcta: Aplicar un Reentrancy Guard desvinculando el callback antes de la mutación y volviéndolo a registrar inmediatamente después:
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. Desvinculamos temporalmente para evitar el bucle recursivo
remove_action('save_post', 'sync_post_slug_on_save', 10);
// 2. Realizamos la actualización segura
wp_update_post([
'ID' => $postId,
'post_title' => sanitize_text_field($post->post_title) . ' [Verified]',
]);
// 3. Volvemos a registrar el handler para operaciones posteriores
add_action('save_post', 'sync_post_slug_on_save', 10, 2);
}En qué consiste el error: Realizar peticiones HTTP externas con wp_remote_get() o consultas complejas a base de datos dentro de hooks como init, wp_head o the_content.
Por qué lo hacen los desarrolladores: Deseo de sincronizar datos remotos al vuelo durante la carga de la página del usuario.
Qué ocurre en producción: Si una API externa tarda 2–3 segundos en responder, el TTFB de la web se incrementa en ese mismo intervalo para cada visitante. En momentos de tráfico elevado, el pool de PHP-FPM colapsa provocando errores HTTP 504 Gateway Timeout.
Forma correcta: Delegar el procesamiento pesado a trabajadores en segundo plano (Action Scheduler o WP-Cron) y servir datos cacheados con transients en llamadas síncronas:
// ERROR: Bloquea la renderización mientras espera a la API externa
add_action('init', function(): void {
$crmData = wp_remote_get('https://api.crm.internal/sync'); // ¡El TTFB se degrada!
});
// CORRECTO: Programación de evento asíncrono con 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);
}
});El callback se procesa en add_filter(). El núcleo genera un hash determinista mediante _wp_filter_build_unique_id() usando spl_object_hash() o el nombre de la función.
Se instancia WP_Hook en $wp_filter global si el tag se registra por primera vez en la petición.
El callback y sus metadatos de accepted_args se colocan en $callbacks según la clave entera de prioridad (10 por defecto).
Se marca la caché de ordenación como dirty. La ordenación mediante ksort() se pospone hasta el disparo del evento.
El tag se añade a la pila $wp_current_filter. En WP_Hook, se almacena una instantánea de prioridades en iterations[] para el nivel de recursión.
La pila iterations[] garantiza que agregar o remover hooks durante la iteración no altere el puntero interno.
El iterador recorre las prioridades de menor a mayor. Los argumentos se desempaquetan y envían según accepted_args.
Para Actions el retorno se descarta. Para Filters el resultado sobreescribe $value para el siguiente paso del pipeline.
Tras recorrer la última prioridad, el marco se elimina de iterations[] y el tag se extrae de $wp_current_filter.
El valor final se entrega al llamador y la memoria del marco se libera para el recolector de basura de PHP.