WordPress funciona con Hooks (Acciones y Filtros). A veces, cosas inesperadas suceden: el contenido desaparece, los títulos cambian, los estilos se rompen. Sospechas que un plugin está interfiriendo, pero ¿cuál?
Conoce más sobre el desarrollo profesional de WordPress en WPPoland.
Necesitas saber exactamente que funciones están adjuntas a un hook específico (ej. the_content o wp_head).
La forma más rápida de depurar eso es inspeccionar $wp_filter, listar callbacks por prioridad y rastrear si cada callback proviene del core, un plugin o el tema activo.
Qué son las acciones y filtros en WordPress
El sistema de hooks de WordPress es lo que lo hace extensible. Cada función importante en WordPress dispara hooks, permitiendo que plugins y temas modifiquen el comportamiento sin editar archivos del core.
Dos tipos de hooks:
- Acciones: ejecutan código en un punto concreto y no devuelven nada (ej.
init,wp_head,wp_footer) - Filtros: reciben un valor, lo modifican y deben devolverlo (ej.
the_title,the_content)
Las dos viven en el mismo registro. add_action() es un envoltorio mínimo que llama a add_filter(), así que una sola rutina de inspección cubre ambas.
Al depurar, necesitas ver:
- Que funciones están enganchadas
- Sus prioridades (orden de ejecución)
- Qué plugin o tema las declara
- Los nombres de las funciones callback
Cómo guarda $wp_filter los callbacks de un hook
Antes de leer una tabla de callbacks conviene saber qué estás leyendo. Desde WordPress 4.7, cada entrada de $wp_filter no es un array sino un objeto WP_Hook, definido en wp-includes/class-wp-hook.php. Esa clase implementa Iterator y ArrayAccess, lo que mantiene la compatibilidad con los snippets anteriores a esa versión, que recorrían $wp_filter['the_content'] con foreach como si fuera un array.
La compatibilidad tiene un límite: las funciones de PHP que reciben el array por referencia fallan sobre un objeto. Por eso el snippet de este artículo ordena una copia de ->callbacks y nunca pasa $wp_filter[ $hook_name ] a ksort(). Si copias un snippet antiguo y ves un error de ksort() quejándose de haber recibido un objeto donde esperaba un array (must be of type array, WP_Hook given en PHP 8), esa es la causa exacta. WP_Hook tampoco implementa Countable, así que count( $wp_filter[ $hook_name ] ) no devuelve el número de callbacks registrados: hay que contar sobre ->callbacks.
La estructura interna tiene tres niveles:
$wp_filter[ $hook ]es el objetoWP_Hook.$wp_filter[ $hook ]->callbackses un array indexado por prioridad (entero).- Cada prioridad contiene un array indexado por un identificador único, con la forma
array( 'function' => $callable, 'accepted_args' => int ). No se guarda nada más: ni qué plugin registró el callback, ni la ruta del archivo, ni una marca de tiempo. Todo lo que la columna de fuente muestra se reconstruye en el momento de la inspección con la Reflection API.
Ese identificador lo genera _wp_filter_build_unique_id() en wp-includes/plugin.php. Para una función global es el propio nombre. Para un método estático es Clase::metodo. Para un método de instancia o un closure se deriva de la identidad del objeto: spl_object_hash() hasta WordPress 7.0 y spl_object_id() desde 7.1, versión en la que los identificadores de closures llevan además el prefijo spl_object_id: (añadido en 7.1.1 para que la clave nunca se convierta en entero). En ambos casos el identificador describe un objeto dentro de una petición. Esto explica por qué no se puede guardar el identificador de un callback de objeto en una opción y reutilizarlo después, por qué un closure no se elimina escribiéndolo otra vez y por qué un método de instancia solo se elimina con la misma instancia que lo registró. Desde 6.9 la misma función devuelve null para un callback de array mal formado, y add_filter() entonces no registra nada y no avisa.
Un nombre de hook solo aparece en $wp_filter cuando algo se ha enganchado a él. Que falte la clave no significa que el hook no se dispare: significa que ahora mismo nadie lo escucha.
accepted_args decide cuántos argumentos recibe el callback, y el fallo va en la dirección contraria a la que se suele suponer. Pasar de más es inofensivo: con add_filter( 'the_content', 'mi_funcion', 10, 2 ) y una función que declara un solo parámetro, PHP descarta el segundo y el filtro sigue funcionando. Pasar de menos rompe la petición: con add_filter( 'the_content', 'mi_funcion', 10, 1 ) y una firma mi_funcion( $contenido, $extra ) sin valor por defecto, PHP lanza ArgumentCountError: Too few arguments, que desde PHP 7.1 es un error fatal y no el aviso que era antes. Cuando una entrada deja de renderizarse tras tocar un filtro, comparar accepted_args con la firma real de la función suele ser más rápido que leer el stack trace.
Función PHP para listar las funciones de un hook
Agrega esta función mejorada a tu functions.php o úsala en un plugin Must-Use durante el desarrollo:
/**
* Inspect WordPress hooks with detailed information
*
* @param string $hook_name The hook to inspect (e.g., 'the_content', 'wp_head')
* @param bool $show_details Show full callback details
* @return void
*/
function wppoland_inspect_hook( $hook_name, $show_details = false ) {
global $wp_filter;
if ( ! isset( $wp_filter[ $hook_name ] ) ) {
echo '<div style="background:#fff3cd; border:2px solid #ffc107; padding:15px; margin:20px 0;">';
echo '<strong>Hook ' . esc_html( $hook_name ) . ' has no attached functions.</strong>';
echo '</div>';
return;
}
// Work on a copy: sorting the live WP_Hook object would change global state.
$callbacks_by_priority = $wp_filter[ $hook_name ]->callbacks;
ksort( $callbacks_by_priority, SORT_NUMERIC );
$plugin_dir = wp_normalize_path( WP_PLUGIN_DIR );
$mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR );
$theme_root = wp_normalize_path( get_theme_root() );
echo '<div style="background:#fff; border:2px solid #dc3545; padding:20px; margin:20px 0; font-family:monospace; font-size:12px; max-width:100%; overflow-x:auto;">';
echo '<h2 style="margin-top:0; color:#dc3545;">Debugging Hook: <code>' . esc_html( $hook_name ) . '</code></h2>';
echo '<table style="width:100%; border-collapse:collapse;">';
echo '<thead><tr style="background:#f8f9fa;"><th style="padding:8px; text-align:left; border:1px solid #dee2e6;">Priority</th><th style="padding:8px; text-align:left; border:1px solid #dee2e6;">Function</th><th style="padding:8px; text-align:left; border:1px solid #dee2e6;">Source</th></tr></thead>';
echo '<tbody>';
foreach ( $callbacks_by_priority as $priority => $callbacks ) {
foreach ( $callbacks as $callback ) {
$function = $callback['function'];
$function_name = 'Unknown';
$source_file = '';
$error = '';
try {
if ( is_string( $function ) ) {
$function_name = $function;
if ( strpos( $function, '::' ) !== false ) {
$reflection = new ReflectionMethod( ...explode( '::', $function, 2 ) );
} elseif ( function_exists( $function ) ) {
$reflection = new ReflectionFunction( $function );
}
} elseif ( is_array( $function ) ) {
$class = is_object( $function[0] ) ? get_class( $function[0] ) : $function[0];
$function_name = $class . '::' . $function[1];
$reflection = new ReflectionMethod( $function[0], $function[1] );
} elseif ( $function instanceof Closure ) {
$function_name = 'Closure';
$reflection = new ReflectionFunction( $function );
} elseif ( is_object( $function ) ) {
$function_name = get_class( $function ) . '::__invoke';
$reflection = new ReflectionMethod( $function, '__invoke' );
}
if ( isset( $reflection ) && $reflection->getFileName() ) {
$source_file = wp_normalize_path( $reflection->getFileName() ) . ':' . $reflection->getStartLine();
}
} catch ( ReflectionException $e ) {
$error = $e->getMessage();
}
unset( $reflection );
// Detect plugin, must-use plugin or theme from the real directory constants.
$source_type = 'Other';
$owner = $source_file;
if ( preg_match( '#/wp-(includes|admin)/#', $source_file ) ) {
$source_type = 'Core';
$owner = 'WordPress';
}
foreach ( array( 'Plugin' => $plugin_dir, 'MU plugin' => $mu_plugin_dir, 'Theme' => $theme_root ) as $type => $dir ) {
if ( strpos( $source_file, $dir . '/' ) === 0 ) {
$source_type = $type;
$relative = substr( $source_file, strlen( $dir ) + 1 );
$owner = strtok( $relative, '/:' );
break;
}
}
if ( '' === $source_file ) {
$source_type = 'Unknown';
$owner = '' !== $error ? $error : 'internal or unresolved';
}
echo '<tr style="border-bottom:1px solid #dee2e6;">';
echo '<td style="padding:8px; border:1px solid #dee2e6;"><strong>' . esc_html( $priority ) . '</strong></td>';
echo '<td style="padding:8px; border:1px solid #dee2e6;"><code>' . esc_html( $function_name ) . '</code></td>';
echo '<td style="padding:8px; border:1px solid #dee2e6;">' . esc_html( $source_type . ': ' . $owner ) . '</td>';
echo '</tr>';
if ( $show_details ) {
echo '<tr><td colspan="3" style="padding:4px 8px; font-size:11px; color:#666; border:1px solid #dee2e6;">';
echo 'File: ' . esc_html( $source_file );
echo '</td></tr>';
}
}
}
echo '</tbody></table>';
echo '</div>';
}
// Usage Examples:
// add_action( 'wp_footer', function(){ wppoland_inspect_hook('the_content'); } );
// add_action( 'wp_footer', function(){ wppoland_inspect_hook('wp_head', true); } ); // With detailsCómo mostrar los hooks en el footer de WordPress
Uso básico
Agrega esto temporalmente a tu functions.php:
// Inspeccionar el hook the_content
add_action( 'wp_footer', function() {
if ( current_user_can( 'manage_options' ) ) { // Solo para admins
wppoland_inspect_hook( 'the_content' );
}
} );Visita cualquier página y desplázate al footer. Verás una tabla detallada mostrando todas las funciones enganchadas a the_content.
Cómo inspeccionar varios hooks a la vez
add_action( 'wp_footer', function() {
if ( ! current_user_can( 'manage_options' ) ) return;
$hooks_a_verificar = array( 'the_content', 'wp_head', 'the_title', 'excerpt_length' );
foreach ( $hooks_a_verificar as $hook ) {
wppoland_inspect_hook( $hook );
}
} );Cómo mostrar el archivo y la línea de cada callback
// Show file paths and line numbers
add_action( 'wp_footer', function() {
if ( current_user_can( 'manage_options' ) ) {
wppoland_inspect_hook( 'the_content', true ); // true = show details
}
} );Por qué $wp_filter sale vacío en un hook
$wp_filter no es un registro estático del sitio: es el estado de las suscripciones en el instante en que lo lees. Un callback que se registra dentro de otro hook no aparece si inspeccionas antes de que ese hook se haya disparado. Es un fallo fácil de cometer y lleva al diagnóstico equivocado, porque la tabla parece completa.
Tres casos concretos:
- Un plugin registra sus filtros dentro de
inito dewp. Si vuelcas la tabla enplugins_loaded, esos callbacks no existen todavía. - Un plugin llama a
remove_filter()entemplate_redirectpara desactivar algo del tema. Si vuelcas enwp_footer, el callback eliminado ya no aparece y concluyes que nunca estuvo. - Un callback solo se registra en el admin, dentro de
admin_init. En una petición del front no estará, aunque sea el responsable del comportamiento que ves en el escritorio.
La comprobación que resuelve la ambigüedad es volcar el mismo hook en dos momentos y comparar:
add_action( 'plugins_loaded', function() {
error_log( 'plugins_loaded: ' . wp_json_encode( array_keys( (array) $GLOBALS['wp_filter']['the_content']->callbacks ) ) );
}, PHP_INT_MAX );
add_action( 'wp_footer', function() {
error_log( 'wp_footer: ' . wp_json_encode( array_keys( (array) $GLOBALS['wp_filter']['the_content']->callbacks ) ) );
}, PHP_INT_MAX );Si las dos líneas de debug.log difieren, hay registro o eliminación tardía y necesitas inspeccionar en el punto correcto. Para hooks del front, wp_footer con prioridad PHP_INT_MAX es el punto más tardío razonable. Para hooks del admin usa admin_footer. Para hooks que se ejecutan antes de que exista salida HTML, como pre_get_posts, la única lectura fiable es la que escribe en error_log(), no la que imprime una tabla.
WordPress incluye cinco funciones que responden a la pregunta del momento sin código propio. did_action( 'init' ) devuelve cuántas veces se ha disparado esa acción hasta ahora; el contador de $wp_actions se incrementa cuando do_action() empieza, antes de ejecutar los callbacks, así que dentro de un callback de init ya vale 1. Es la forma más rápida de demostrar que un hook se disparó. current_filter() devuelve el nombre del hook que se está ejecutando. doing_action() y doing_filter() responden lo mismo para un nombre concreto. did_filter() llegó en WordPress 6.1 y hace para los filtros lo que did_action() hace para las acciones. La global $wp_current_filter guarda la pila anidada completa, y eso importa porque los filtros se disparan a menudo dentro de otros filtros y el más interno no siempre es el que estás depurando.
Si el nombre del hook falta de verdad en $wp_filter y did_action() devuelve un número distinto de cero, el hook se dispara y no tiene nada enganchado. No es un fallo, y repetir el snippet no lo cambiará. El problema está en otra parte.
Otro caso de tabla vacía: el hook simplemente no tiene suscriptores porque escribiste mal el nombre. the_excerpt y get_the_excerpt son hooks distintos, igual que wp_enqueue_scripts y admin_enqueue_scripts. Antes de dar por bueno un “no tiene funciones adjuntas”, confirma el nombre con has_filter( 'nombre_del_hook' ), que devuelve false cuando no hay nada registrado, o revisa las claves reales con array_keys( $GLOBALS['wp_filter'] ) filtradas por una subcadena.
En qué orden se ejecutan los hooks según su prioridad
Columna de prioridad
WordPress ejecuta hooks en orden de prioridad (números menores primero). La prioridad predeterminada es 10.
Prioridades comunes:
1-9: Ejecución temprana (antes del predeterminado)10: Prioridad predeterminada11-99: Ejecución tardía (después del predeterminado)999: Muy tardía (casi última)
Ejemplo: los callbacks que el propio core de WordPress engancha a the_content, tal como se registran en wp-includes/default-filters.php y wp-includes/class-wp-embed.php:
Priority 8: WP_Embed::run_shortcode, WP_Embed::autoembed, apply_block_hooks_to_content_from_post_object
Priority 9: do_blocks
Priority 10: wptexturize, wpautop, shortcode_unautop, prepend_attachment, wp_replace_insecure_home_url
Priority 11: capital_P_dangit, do_shortcode (comment in core: "AFTER wpautop()")
Priority 12: wp_filter_content_tags
Priority 20: convert_smiliesEn el código fuente, la línea de do_shortcode lleva el comentario AFTER wpautop(), es decir, se ejecuta después de wpautop(). Los callbacks que comparten prioridad se ejecutan en el orden en que se añadieron, como documenta WP_Hook::add_filter(), y por eso cinco filtros del core conviven en la prioridad 10 sin conflicto. Un callback de plugin en prioridad 10 añadido después de wpautop ya recibe el contenido con etiquetas de párrafo.
Columna de función
Muestra el nombre del callback:
- Funciones con nombre:
wpautop,do_shortcode - Métodos de clase:
MiPlugin::procesar_contenido - Closures:
Closure(funciones anónimas) - Objetos invocables:
NombreClase::__invoke
Columna de fuente
Indica dónde está declarado el callback. WordPress no lo guarda, así que el snippet lo reconstruye a partir del archivo que devuelve Reflection:
- Core: un archivo dentro de
wp-includesowp-admin - Plugin o MU plugin: el nombre de la carpeta dentro de
WP_PLUGIN_DIRoWPMU_PLUGIN_DIR - Theme: la carpeta del tema dentro de
get_theme_root() - Other: cualquier otra cosa, por ejemplo un drop-in como
object-cache.php, con la ruta completa - Unknown: una función interna de PHP, o el mensaje de error de Reflection
El archivo que declara no siempre es el que registra. Un plugin que engancha una función del core como wp_strip_all_tags a un filtro aparece como Core, porque Reflection informa de dónde está definida la función, no de quién llamó a add_filter().
Qué pasa si dos callbacks tienen la misma prioridad
Dos callbacks con la misma prioridad se ejecutan en el orden en que fueron registrados, que en la práctica es el orden de carga de los plugins. Ese orden lo determina la opción active_plugins, que WordPress reordena alfabéticamente por la ruta del archivo principal cada vez que se activa un plugin, y los MU-Plugins se cargan siempre antes. Consecuencia práctica: si dos plugins pelean por the_content en la prioridad 10, renombrar la carpeta de uno cambia el resultado, y esa es exactamente la clase de arreglo que se rompe en el siguiente despliegue. La solución estable es cambiar la prioridad de forma explícita, no confiar en el orden de carga.
Para ver el orden real sin adivinar:
wp option get active_plugins --format=jsonCómo evitar la recursión al inspeccionar the_content
Si llamas al inspector desde dentro del propio hook que estás inspeccionando, o si imprimes HTML que vuelve a pasar por the_content, entras en recursión y el proceso termina con agotamiento de memoria. Por eso el snippet se engancha a wp_footer y no a the_content. Cuando necesites trazar el paso por un filtro sin recursión, usa doing_filter( 'the_content' ) como guarda y did_action() para contar cuántas veces se ha disparado una acción en la petición.
Qué hook revisar si desaparece el contenido o cambia el título
Escenario 1: El contenido desaparece
wppoland_inspect_hook( 'the_content' );Busca funciones que retornen cadenas vacías o tengan prioridad alta.
Escenario 2: Títulos cambian inesperadamente
wppoland_inspect_hook( 'the_title' );Busca plugins SEO o de traducción que modifiquen títulos.
Escenario 3: Estilos se rompen
wppoland_inspect_hook( 'wp_enqueue_scripts' );Busca plugins que eliminen hojas de estilo o conflictos de prioridad.
Cómo comprobar qué callback causa el problema
Una tabla de callbacks es una lista de sospechosos, no una prueba. El paso que suele faltar es la falsación: desactiva el callback, repite la petición y comprueba si el síntoma desaparece. Si no desaparece, el sospechoso era inocente y hay que seguir buscando.
Para funciones con nombre basta remove_filter(), pero el nombre y la prioridad tienen que coincidir exactamente con los del registro, y la llamada debe ejecutarse después de que ese registro haya ocurrido:
add_action( 'wp_loaded', function() {
remove_filter( 'the_content', 'wpautop', 10 );
}, PHP_INT_MAX );Para métodos de instancia el problema es que necesitas el mismo objeto que hizo el registro, y la mayoría de los plugins no lo exponen. La vía que funciona sin acceder al plugin es localizar la entrada por su identificador único dentro de WP_Hook y devolver a remove_filter() el callable original que contiene esa entrada:
function wppoland_remove_callback_by_name( $hook, $needle, $priority = 10 ) {
global $wp_filter;
if ( ! isset( $wp_filter[ $hook ]->callbacks[ $priority ] ) ) {
return false;
}
foreach ( $wp_filter[ $hook ]->callbacks[ $priority ] as $id => $registro ) {
if ( false !== strpos( $id, $needle ) ) {
remove_filter( $hook, $registro['function'], $priority );
return $id;
}
}
return false;
}El identificador contiene Clase::metodo para métodos estáticos y el nombre del método al final para métodos de instancia, así que $needle puede ser 'filtrar_contenido' o 'MiClase::'. La función devuelve el identificador eliminado, lo cual sirve como confirmación de que actuó sobre algo. Pasar por remove_filter() en lugar de hacer unset() sobre el array importa: WP_Hook mantiene una lista aparte de prioridades (la propiedad protegida $priorities, añadida en WordPress 6.4) y ajusta cualquier iteración en curso, y WP_Hook::remove_filter() actualiza ambas cosas cuando un grupo de prioridad queda vacío. Un unset() directo no actualiza ninguna.
Cómo comprobar si una función concreta está enganchada
function wppoland_is_function_hooked( $hook_name, $function_name ) {
global $wp_filter;
if ( ! isset( $wp_filter[ $hook_name ] ) ) {
return false;
}
foreach ( $wp_filter[ $hook_name ]->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $callback ) {
if ( is_string( $callback['function'] ) && $callback['function'] === $function_name ) {
return array( 'priority' => $priority, 'found' => true );
}
if ( is_array( $callback['function'] ) && $callback['function'][1] === $function_name ) {
return array( 'priority' => $priority, 'found' => true );
}
}
}
return false;
}
// Usage
if ( wppoland_is_function_hooked( 'the_content', 'wpautop' ) ) {
echo 'wpautop is hooked to the_content';
}Cómo quitar un hook temporalmente
// Remove a problematic hook (the priority must match the one it was added with)
remove_filter( 'the_content', 'problematic_function', 10 );
// Or remove every callback whose class or function name contains a prefix
global $wp_filter;
if ( isset( $wp_filter['the_content'] ) ) {
// foreach works on a copy of the array, so removing inside the loop is safe
foreach ( $wp_filter['the_content']->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $callback ) {
$function = $callback['function'];
$name = '';
if ( is_string( $function ) ) {
$name = $function;
} elseif ( is_array( $function ) ) {
$name = is_object( $function[0] ) ? get_class( $function[0] ) : (string) $function[0];
} elseif ( is_object( $function ) && ! ( $function instanceof Closure ) ) {
$name = get_class( $function );
}
if ( '' !== $name && strpos( $name, 'ProblemPlugin' ) !== false ) {
remove_filter( 'the_content', $function, $priority );
}
}
}
}Por qué remove_filter no hace nada sin avisar
remove_filter() y remove_action() devuelven un booleano y nunca avisan. Cuando devuelven false no ha pasado nada y la página sigue igual, así que el fallo es silencioso y se confunde con facilidad con un problema de caché. Hay cuatro causas distintas. Las dos primeras se ven en la tabla de inspección, las otras dos no.
Prioridad equivocada. La eliminación compara la prioridad además del callback. Si el plugin se registró en la prioridad 20 y eliminas en la 10 por defecto, la llamada falla. La columna de prioridad existe precisamente para evitarlo: léela, no supongas 10.
Identidad equivocada del callback. Para un método de instancia, WordPress compara el identificador único construido a partir de esa instancia exacta. Crear una instancia nueva de la misma clase y pasarla a remove_filter() produce otro identificador y no elimina nada. Necesitas el objeto original, que solo se obtiene leyendo $callback['function'][0] en $wp_filter o mediante un accesor público que exponga el plugin.
La eliminación se ejecuta antes del registro. Quitar un hook no es declarativo. Si tu código corre en plugins_loaded y el plugin registra en init, eliminaste un callback que todavía no existía y se añade un momento después. Mueve la eliminación a un hook posterior, o al mismo hook con un número de prioridad mayor.
El callback es un closure. No hay nombre que pasar ni forma de reconstruir el mismo objeto escribiéndolo otra vez; la sección siguiente explica cómo sacarlo de $wp_filter.
Comprueba también bien el valor devuelto. has_filter() devuelve false cuando el callback no está enganchado y la prioridad entera cuando sí lo está. La prioridad 0 es un valor legítimo y en PHP se evalúa como falso, así que compara con !== false y no con una prueba de veracidad. Desde WordPress 6.9, has_filter() y has_action() aceptan un tercer argumento $priority; si pasas a la vez un callback y una prioridad, el resultado es un booleano para esa prioridad exacta, la comprobación más limpia antes de llamar a remove_filter().
Cómo eliminar una función anónima de un hook
Como el identificador único se regenera en cada petición, la única forma fiable de desenganchar una función anónima es encontrar el objeto closure en el array de callbacks durante la misma petición y devolver ese mismo objeto a remove_filter(). WordPress construye el identificador a partir de la identidad del objeto, así que el objeto que sacaste produce exactamente la clave con la que se guardó:
function wppoland_remove_closure( $hook_name, $priority, $needle ) {
global $wp_filter;
if ( ! isset( $wp_filter[ $hook_name ]->callbacks[ $priority ] ) ) {
return false;
}
foreach ( $wp_filter[ $hook_name ]->callbacks[ $priority ] as $id => $callback ) {
if ( ! ( $callback['function'] instanceof Closure ) ) {
continue;
}
$reflection = new ReflectionFunction( $callback['function'] );
if ( strpos( (string) $reflection->getFileName(), $needle ) !== false ) {
return remove_filter( $hook_name, $callback['function'], $priority );
}
}
return false;
}Compara con el archivo de declaración que devuelve Reflection, no con un nombre que hayas adivinado. Llama a la función lo bastante tarde para que el closure ya esté registrado y lo bastante pronto para que el hook todavía no se haya disparado.
Protocolo de verificación, en orden:
- Anota el síntoma de forma medible: una cadena que falta en el HTML, un
<style>que no se imprime, un valor concreto en la respuesta. - Compara contra
curl -s https://ejemplo.com/una-entrada/ | grep -c "cadena_esperada"antes del cambio. - Elimina el callback y repite el mismo
curl. Un contador que pasa de 0 a 1 es una prueba; “parece que ya funciona” no lo es. - Restaura el callback y confirma que el síntoma vuelve. Si no vuelve, el arreglo fue una coincidencia, probablemente una caché.
- Solo entonces escribe el arreglo definitivo en un plugin propio, con la prioridad explícita.
El paso 4 detecta el error más caro de esta clase de depuración: haber limpiado una caché de objetos o de página al mismo tiempo que se quitó el callback, y atribuir el efecto al callback.
Qué plugin registró un closure o un método
Las versiones antiguas de este snippet detectaban la fuente con strpos( $source_file, 'wp-content/plugins' ), y esa comparación falla en tres entornos habituales:
- Instalaciones tipo Bedrock, donde el directorio de contenido se llama
appy los plugins viven enweb/app/plugins. - Plugins montados por enlace simbólico durante el desarrollo, donde
getFileName()devuelve la ruta real fuera dewp-content. - Windows, donde las rutas usan barras invertidas y la comparación literal contra
wp-content/pluginsnunca coincide.
El snippet actual ya compara contra las constantes que define el propio WordPress. Si necesitas además distinguir el tema hijo del tema padre, esta variante lo hace, normalizando primero las barras:
function wppoland_localizar_fuente( $archivo ) {
$archivo = wp_normalize_path( $archivo );
$mapa = array(
'MU-Plugin' => defined( 'WPMU_PLUGIN_DIR' ) ? WPMU_PLUGIN_DIR : '',
'Plugin' => defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : '',
'Tema hijo' => is_child_theme() ? get_stylesheet_directory() : '',
'Tema' => get_template_directory(),
'Core' => ABSPATH . WPINC,
);
foreach ( $mapa as $etiqueta => $ruta ) {
if ( $ruta && 0 === strpos( $archivo, wp_normalize_path( $ruta ) ) ) {
return $etiqueta;
}
}
return 'Desconocido';
}Con la ruta ya identificada como plugin, plugin_basename( $archivo ) devuelve carpeta/archivo.php, y quedarse con el primer segmento da la carpeta del plugin responsable. Ese es el dato que necesitas para desactivarlo o para abrir un informe en su repositorio.
Los closures requieren un paso más, porque no tienen nombre. ReflectionFunction sobre el closure da archivo y línea, que suele ser suficiente, pero cuando el closure vive en un archivo de arranque compartido conviene saber a qué clase pertenecía:
$reflection = new ReflectionFunction( $callback['function'] );
$ambito = $reflection->getClosureScopeClass();
$objeto = $reflection->getClosureThis();
$etiqueta = 'Closure';
if ( $ambito ) {
$etiqueta .= ' en ' . $ambito->getName();
} elseif ( $objeto ) {
$etiqueta .= ' ligado a ' . get_class( $objeto );
}getClosureScopeClass() devuelve la clase donde se declaró el closure y getClosureThis() el objeto al que está ligado, así que un callback anónimo deja de serlo en la práctica.
Cuando la columna de fuente muestra Unknown con un mensaje de Reflection no hay un fallo en el snippet. ReflectionMethod lanza ReflectionException cuando el método no existe declarado en la clase, y el caso habitual es un plugin que enruta llamadas por el método mágico __call(). El bloque try del snippet inicial captura esa excepción y muestra su mensaje en la columna de fuente. Si la ves, abre la clase y busca el método: si no está declarado, el código que se ejecuta vive en el despachador y el nombre de la tabla es solo el nombre lógico del callback.
Errores del snippet de depuración de hooks en PHP 8
Un código de depuración que falla es peor que no tener ninguno. Las revisiones anteriores de este snippet, incluida la que publicaba este artículo, fallan en PHP 8 en cuatro puntos, y el código de arriba evita cada uno.
strpos() sobre un callback. Un bucle que llama a strpos( $callback['function'], 'ProblemPlugin' ) solo funciona mientras function sea una cadena. En cuanto llega a un callback de array o a un closure, PHP 8 lanza un TypeError, porque las funciones internas ya no aceptan un array u objeto donde esperan una cadena. Resuelve primero un nombre con is_string(), is_array() y is_object() y compare después, como hace el ejemplo de eliminación masiva.
ksort() sobre el objeto vivo. Ordenar $wp_filter[ $hook ]->callbacks en el sitio modifica el estado global durante la misma petición que intenta observar. WP_Hook::add_filter() ya mantiene los grupos ordenados llamando a ksort( $this->callbacks, SORT_NUMERIC ) cada vez que aparece una prioridad nueva, así que ordenar no aporta nada a la corrección. La función de inspección ordena una copia local.
Excepciones de Reflection. new ReflectionFunction() acepta un nombre de función o un Closure y lanza ReflectionException con una cadena del tipo Clase::metodo. En PHP 8.4, pasar una única cadena Clase::metodo a new ReflectionMethod() emite además un aviso de obsolescencia que recomienda ReflectionMethod::createFromMethodName(); dividir la cadena y pasar dos argumentos funciona en cualquier versión de PHP 8. La función lo envuelve todo en un único try/catch e imprime el mensaje de la excepción, de modo que un método inexistente y uno mágico se distinguen.
Rutas fijas. Detectar la fuente con las subcadenas wp-content/plugins y wp-content/themes etiqueta mal cada fila en sitios que movieron el directorio de contenido con WP_CONTENT_DIR y nunca reconoce los MU-Plugins. La función compara contra WP_PLUGIN_DIR, WPMU_PLUGIN_DIR y get_theme_root() tras wp_normalize_path(), lo que además hace comparables las rutas de Windows. Queda un caso: Reflection devuelve la ruta resuelta de un plugin enlazado simbólicamente, así que ese callback cae en la fila Other con su ruta real.
Cómo listar los hooks de WordPress con WP-CLI
Editar functions.php en un sitio en producción es la peor parte de este flujo. WP-CLI permite leer $wp_filter sin tocar ningún archivo:
wp eval 'global $wp_filter; foreach ( $wp_filter["the_content"]->callbacks as $p => $cbs ) { foreach ( $cbs as $id => $cb ) { echo $p . "\t" . ( is_string( $cb["function"] ) ? $cb["function"] : $id ) . PHP_EOL; } }' --url=https://ejemplo.com/una-entrada/WP-CLI arranca WordPress pero nunca ejecuta la pila de plantillas, así que template_redirect, wp_head y wp_footer no se disparan y lo que se registra dentro de ellos falta en el volcado. Los callbacks añadidos en plugins_loaded, after_setup_theme e init sí están, y eso cubre la mayoría de los registros de plugins. Además, WP-CLI corre sin usuario conectado salvo que pases --user, así que los registros condicionados a capacidades se comportan distinto de lo que viste en el navegador. Y wp eval ejecuta tu código dentro de un método, por lo que la documentación del comando advierte que las globales como $wp_filter deben declararse antes con global, cosa que hace el comando de arriba. La misma documentación lista una opción --hook=<hook> que ejecuta el código después de que se dispare un hook, pero la marca como no incluida todavía en una versión estable de WP-CLI; requiere la compilación nightly (wp cli update --nightly).
El parámetro --url fija el contexto de la petición, host y ruta. Importa porque WP-CLI nunca ejecuta la consulta principal, y sin ese contexto los plugins que condicionan el registro al host o a la URL no llegan a registrar nada. En multisitio es obligatorio: es lo que decide de qué sitio de la red se cargan las opciones y los plugins activos.
Dos flags convierten esto en una prueba de aislamiento en segundos:
# Sin plugins: si el síntoma desaparece, el culpable es un plugin
wp eval-file inspector.php --skip-plugins --url=https://ejemplo.com/
# Sin el tema: aísla los callbacks de functions.php
wp eval-file inspector.php --skip-themes --url=https://ejemplo.com/
# Desactivando un solo plugin sospechoso
wp eval-file inspector.php --skip-plugins=nombre-del-plugin --url=https://ejemplo.com/Para una comprobación sin tabla, compara has_filter() con y sin el plugin sospechoso:
wp plugin list --status=active --field=name
wp eval 'echo has_filter( "the_content", "wpautop" ) === false ? "absent" : "priority " . has_filter( "the_content", "wpautop" );'
wp eval 'echo has_filter( "the_content", "wpautop" ) === false ? "absent" : "priority " . has_filter( "the_content", "wpautop" );' --skip-plugins=suspect-plugin--skip-plugins acepta una lista separada por comas, así que puedes hacer una bisección: desactiva la mitad de la lista de wp plugin list --status=active --field=name, repite, y en pocas iteraciones queda un candidato. Es más rápido que desactivar plugins uno a uno desde el escritorio y no deja rastro en la base de datos.
Para medir cuánto cuesta cada callback, no solo cuáles existen, el paquete de perfilado de WP-CLI se instala por separado:
wp package install wp-cli/profile-command
wp profile hook the_content --url=https://ejemplo.com/una-entrada/La salida desglosa tiempo y consultas por callback del hook indicado. Cuando el problema es lentitud y no comportamiento, esa tabla señala el responsable directamente, sin instalar ningún plugin en el sitio.
wp shell sirve para exploración interactiva: abre un REPL con WordPress cargado, donde has_filter( 'the_content', 'wpautop' ) devuelve la prioridad registrada o false, y count( $GLOBALS['wp_filter']['init']->callbacks ) da el número de prioridades ocupadas sin escribir un script.
Ver los hooks con el plugin Query Monitor
Para una lista visual, la herramienta habitual es el plugin Query Monitor de John Blackbourn, gratuito en WordPress.org. Su readme lo describe como «el panel de herramientas para desarrolladores de WordPress y WooCommerce» (John Blackbourn, Query Monitor en WordPress.org, traducción propia) y cuenta los hooks y las acciones entre lo que depura.
Por qué usar Query Monitor para depurar hooks
- Interfaz visual: una tabla por petición, filtrable
- Rutas de archivos: cada callback enlaza a su archivo y línea
- Nombres de componentes: cada fila indica el plugin, tema o componente del core responsable
- Sin código: nada que pegar en
functions.php
El panel de hooks no mide el tiempo de ejecución por hook ni por callback. Sus columnas son el nombre del hook, el tipo (acción o filtro, cuando se muestran todos los hooks), la prioridad, el callback y el componente. Para tiempos, Query Monitor tiene paneles aparte, y para el coste de cada callback la herramienta adecuada es un perfilador como wp profile hook.
Instalación
# Vía WP-CLI
wp plugin install query-monitor --activate
# O descárguelo desde WordPress.orgCómo encontrar un hook en Query Monitor
- Instala y activa Query Monitor
- Visita cualquier página del sitio con sesión de administrador
- Abre el menú de Query Monitor en la barra de herramientas, que muestra un resumen de la página actual
- Elige el panel
Hooks & Actions - Filtra por nombre de hook o por componente
- Lee los callbacks enganchados con su prioridad y componente
Un detalle decide si esto sirve para the_content. Por defecto, el recolector de hooks solo lista los nombres de $wp_actions, es decir, las acciones que se dispararon al menos una vez en la petición. Filtros como the_content, the_title o excerpt_length no están en esa lista. Para incluirlos, añade esto a wp-config.php en un sitio de desarrollo:
define( 'QM_SHOW_ALL_HOOKS', true );Con la constante definida, el recolector lee todas las claves de $wp_filter, el panel pasa a llamarse Hooks, Actions, & Filters y una columna de tipo separa acciones y filtros. Una segunda constante, QM_HIDE_CORE_ACTIONS, oculta los callbacks del core, lo que acorta la tabla cuando solo te interesan los plugins. Query Monitor también señala el hook especial all: si algo está enganchado a él, el panel muestra el aviso «Aviso: la acción all es extremadamente intensiva en recursos. Intente evitar usarla» (John Blackbourn, código del panel de hooks de Query Monitor, traducción propia).
Por defecto Query Monitor muestra su salida solo a administradores en sitios individuales y a superadministradores en multisitio, así que es seguro dejarlo activo en una copia de staging.
Cuándo usar Query Monitor o un snippet propio
Usa Query Monitor cuando:
- Necesites una herramienta de depuración permanente
- Quieras ver también consultas a la base de datos, llamadas HTTP y errores de PHP de la misma petición
- Estés depurando varios hooks
- Prefieras una interfaz gráfica al código
Usa un snippet propio cuando:
- Necesites una comprobación rápida y puntual
- Trabajes en un entorno de desarrollo
- Quieras comprobar hooks desde código
- Query Monitor no esté disponible
Buenas prácticas para depurar y nombrar hooks
1. Depura solo en desarrollo
Nunca dejes código de depuración en producción:
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
add_action( 'wp_footer', function() {
wppoland_inspect_hook( 'the_content' );
} );
}2. Restringe a administradores
Siempre verifica capacidades del usuario:
if ( current_user_can( 'manage_options' ) ) {
// Mostrar info de depuración
}3. Usa nombres descriptivos de hooks
Al crear hooks personalizados, usa prefijos:
// Correcto
do_action( 'wppoland_before_content' );
// Incorrecto
do_action( 'before_content' );4. Documenta tus hooks
/**
* Fires before the main content area
*
* @param string $content The post content
*/
do_action( 'wppoland_before_content', $content );Hook que no se dispara o función que no se ejecuta
Por qué mi hook no aparece
Causas posibles:
- El hook todavía no se ha disparado (revisa el momento)
- Una condición impide que se dispare
- El nombre del hook está mal escrito
Solución:
// has_action() without a callback answers "is anything attached", not "does the hook exist"
if ( has_action( 'your_hook_name' ) ) {
echo 'At least one callback is attached';
} else {
echo 'Nothing is attached (the hook may still fire)';
}
// did_action() answers "has it fired on this request"
echo 'Fired ' . did_action( 'your_hook_name' ) . ' time(s)';Por qué mi función enganchada no se ejecuta
Causas posibles:
- Conflicto de prioridades
- Lógica condicional
- Otro plugin eliminó el hook
Solución:
// Check current priority (false means not attached; 0 is a valid priority)
$priority = has_filter( 'the_content', 'your_function' );
echo false === $priority ? 'Not attached' : 'Priority: ' . $priority;Cómo depurar hooks en peticiones REST, AJAX y cron
El snippet imprime HTML. Eso está bien en una página del front vista por un administrador y es un fallo en cualquier petición que espera otro formato. Un echo antes de una respuesta JSON la invalida, y el navegador informa de un error de parseo que no dice nada sobre su causa real. Lo mismo ocurre con las peticiones que aún no han enviado cabeceras: la salida temprana provoca el aviso Cannot modify header information, headers already sent, que a su vez rompe redirecciones y la escritura de cookies de sesión.
Añade una guarda de contexto antes de imprimir:
function wppoland_puede_imprimir_debug() {
if ( ! current_user_can( 'manage_options' ) ) {
return false;
}
if ( wp_doing_ajax() || wp_doing_cron() ) {
return false;
}
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return false;
}
if ( function_exists( 'wp_is_json_request' ) && wp_is_json_request() ) {
return false;
}
if ( defined( 'WP_CLI' ) && WP_CLI ) {
return false;
}
return true;
}Cuando el hook que investigas solo se dispara en uno de esos contextos, la salida tiene que ir a un canal que no contamine la respuesta. error_log( print_r( $datos, true ) ) escribe en wp-content/debug.log si WP_DEBUG_LOG está activo. Si ya tienes Query Monitor instalado, do_action( 'qm/debug', $datos ) deja el volcado en su panel de registros. Query Monitor no guarda los datos entre peticiones: en una petición REST autenticada añade un resumen de rendimiento y errores de PHP en las cabeceras de la respuesta, y una petición con _envelope incluye más datos en la propiedad qm.
El ancla adecuada para este estilo de inspección es la acción shutdown. Se dispara al final de casi cualquier tipo de petición, después de todos los registros condicionales, y no depende de que haya plantilla. Combínala con wp_doing_ajax(), wp_doing_cron() y defined( 'REST_REQUEST' ) para que cada línea del registro indique qué tipo de petición la produjo. Sin esa etiqueta, un registro lleno de volcados de the_content de peticiones mezcladas no se puede leer.
Cómo registrar el orden en que se disparan los hooks de WordPress
Listar lo que está enganchado responde a una pregunta. No responde a la otra: en qué orden se ejecutaron las cosas en esta petición. WordPress reserva el nombre de hook all. Cada llamada a do_action() y apply_filters() lo comprueba antes de ejecutar los callbacks del propio hook y lo despacha mediante _wp_call_all_hook(), así que un callback enganchado a all ve cada acción y cada filtro que se dispara:
add_action( 'all', function() {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return;
}
$hook = current_filter();
if ( strpos( $hook, 'the_' ) !== 0 && strpos( $hook, 'wp_' ) !== 0 ) {
return;
}
error_log( sprintf( '[hook] %s (depth %d)', $hook, count( $GLOBALS['wp_current_filter'] ) ) );
} );Dos reglas lo hacen utilizable. Regístralo lo antes posible, idealmente desde un MU-Plugin, porque lo que se dispara antes de enganchar el callback es invisible. Y filtra siempre por prefijo antes de escribir, porque una sola petición del front dispara cientos de hooks y una traza sin filtrar produce un registro que nadie leerá. La profundidad que da $wp_current_filter indica que un filtro se ejecutó anidado dentro de otro, la explicación habitual de un callback que parece ejecutarse dos veces.
El coste es real: all añade una llamada a función en cada invocación de hook de la petición. Úsalo para encontrar el orden y después bórralo. No pertenece a un MU-Plugin de depuración permanente.
Cómo retirar el código de depuración de hooks
El inspector es código temporal, y el modo de fallo característico es que deje de serlo. Un functions.php con una llamada olvidada sobrevive a actualizaciones del sitio y reaparece meses después en una petición donde nadie lo espera.
Antes de cerrar la tarea, verifica en este orden:
- El código vive en un MU-Plugin (
wp-content/mu-plugins/inspector-hooks.php), no enfunctions.php. Un cambio de tema no lo arrastra ni lo pierde. grep -rn "wppoland_inspect_hook" wp-content/no devuelve nada fuera de ese archivo.wp plugin list --status=must-useconfirma qué MU-Plugins siguen cargados en el entorno.- Una petición anónima al front no contiene rastro del volcado:
curl -s https://ejemplo.com/ | grep -ci "Depurando Hook"debe devolver0. wp config get WP_DEBUGywp config get WP_DEBUG_DISPLAYdevuelven lo esperado para el entorno. En producción,WP_DEBUG_DISPLAYdebe estar enfalseaunqueWP_DEBUG_LOGsiga activo.debug.logno ha crecido sin control. Un volcado de$wp_filteren cada petición escribe cientos de líneas por visita y el archivo se dispara en un sitio con tráfico, porque WordPress no lo rota.
El punto 4 es el único que prueba el resultado, porque comprueba el HTML servido y no el código fuente. Los cinco anteriores describen la intención; ese mide el artefacto.
Un último apunte sobre el arreglo definitivo. Cuando ya sabes qué callback causa el problema, la corrección correcta casi nunca es editar el plugin ajeno, porque la siguiente actualización la borra. Escribe un plugin propio de una sola función que llame a remove_filter() con la prioridad exacta en wp_loaded, documenta en un comentario qué plugin y qué versión motivaron el cambio, y añade una comprobación de que el callback sigue existiendo. Si un día el plugin lo elimina por su cuenta, quieres enterarte por el registro y no por un comportamiento silencioso.
Resumen
El snippet propio muestra, para cualquier hook:
- Qué funciones están enganchadas
- Sus prioridades de ejecución
- Qué plugin, tema o archivo del core las declara
- La ubicación del archivo fuente
Ideas clave:
- Lee
$wp_filter[ $hook ]->callbacks, la propiedad pública deWP_Hook, y ordena una copia, nunca el objeto vivo - Query Monitor lista filtros como
the_contentsolo cuandoQM_SHOW_ALL_HOOKSestá definida - Elimina callbacks con
remove_filter()y la prioridad exacta, usando el objeto original para métodos y closures - Usa
did_action(),current_filter()y el hookallpara las preguntas de momento y orden - Restringe la salida de depuración al entorno de desarrollo y a administradores
Conoce más sobre los servicios de desarrollo WordPress y la auditoría de seguridad WordPress en WPPoland.







