WordPress runs on Hooks (Actions and Filters). Sometimes, unexpected things happen, content disappears, titles change, styles break. You suspect a plugin is interfering, but which one?
You need to know exactly what functions are attached to a specific hook (e.g., the_content or wp_head).
The fastest way to debug that is to inspect $wp_filter, list callbacks by priority, and trace whether each callback comes from core, a plugin, or the active theme.
Learn more about professional WordPress development at WPPoland.
The WordPress hook system: Understanding the foundation
WordPress’s hook system is what makes it extensible. Every major function in WordPress fires hooks, allowing plugins and themes to modify behavior without editing core files.
Two Types of Hooks:
- Actions, Do something at a specific point (e.g.,
wp_head,the_content) - Filters, Modify data before it’s used (e.g.,
the_title,the_content)
When debugging, you need to see:
- What functions are hooked
- Their priorities (execution order)
- Which plugin/theme added them
- The callback function names
What $wp_filter actually holds
Since WordPress 4.7 the global $wp_filter is an array keyed by hook name, and every value is a WP_Hook object, not a plain nested array. WP_Hook implements ArrayAccess and Iterator purely for backwards compatibility, which is why decade-old snippets that do $wp_filter['the_content'][10] still return something instead of a fatal error. New code should read the public callbacks property directly.
That property is a two-level array. The first level is keyed by priority, as an integer. The second level is keyed by a unique callback ID, and each entry is an array with exactly two keys: function (the callable itself) and accepted_args (how many arguments WordPress will pass to it). Nothing else is stored. There is no record of which plugin registered the callback, no timestamp, no file path. Everything the debugging table above prints in the Source column is reconstructed at inspection time with the Reflection API, which is why that column is the part most likely to be wrong or blank.
The unique callback ID is generated by the internal function _wp_filter_build_unique_id(). For a plain string callback it is the function name. For a static callable written as an array it is the class name joined to the method name. For an object method or a closure it is derived from spl_object_hash(), so it changes on every request and cannot be hardcoded. That is why remove_filter() cannot reach a closure, and why an object method can only be removed using the same instance that registered it.
A hook name only appears in $wp_filter once something has been attached to it. A missing key does not mean the hook never fires. It means nothing is currently listening.
The complete debugging snippet
Add this enhanced function to your functions.php or use it in a Must-Use plugin during development:
/**
* 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 '$hook_name' has no attached functions.</strong>";
echo '</div>';
return;
}
$hook_data = $wp_filter[ $hook_name ];
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>$hook_name</code></h2>";
// Sort by priority
ksort( $hook_data->callbacks );
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 ( $hook_data->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $callback ) {
$function_name = 'Unknown';
$source_file = 'Unknown';
if ( is_string( $callback['function'] ) ) {
$function_name = $callback['function'];
if ( function_exists( $function_name ) ) {
$reflection = new ReflectionFunction( $function_name );
$source_file = $reflection->getFileName() . ':' . $reflection->getStartLine();
}
} elseif ( is_array( $callback['function'] ) ) {
if ( is_object( $callback['function'][0] ) ) {
$function_name = get_class( $callback['function'][0] ) . '::' . $callback['function'][1];
} else {
$function_name = $callback['function'][0] . '::' . $callback['function'][1];
}
try {
$reflection = new ReflectionMethod( $callback['function'][0], $callback['function'][1] );
$source_file = $reflection->getFileName() . ':' . $reflection->getStartLine();
} catch ( ReflectionException $e ) {
$source_file = 'Unable to determine';
}
} elseif ( is_object( $callback['function'] ) ) {
$function_name = 'Closure';
$reflection = new ReflectionFunction( $callback['function'] );
$source_file = $reflection->getFileName() . ':' . $reflection->getStartLine();
}
// Detect plugin/theme
$source_type = 'Core';
if ( strpos( $source_file, 'wp-content/plugins' ) !== false ) {
$source_type = 'Plugin';
preg_match( '/plugins\/([^\/]+)/', $source_file, $matches );
$plugin_name = isset( $matches[1] ) ? $matches[1] : 'Unknown';
} elseif ( strpos( $source_file, 'wp-content/themes' ) !== false ) {
$source_type = 'Theme';
preg_match( '/themes\/([^\/]+)/', $source_file, $matches );
$plugin_name = isset( $matches[1] ) ? $matches[1] : 'Unknown';
} else {
$plugin_name = 'WordPress';
}
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;"><span style="color:' . ( $source_type === 'Plugin' ? '#dc3545' : ( $source_type === 'Theme' ? '#0073aa' : '#28a745' ) ) . ';">' . esc_html( $source_type ) . '</span> - ' . esc_html( $plugin_name ) . '</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 details
How to use the debugging function
Basic usage
Add this to your functions.php temporarily:
// Inspect the_content hook
add_action( 'wp_footer', function() {
if ( current_user_can( 'manage_options' ) ) { // Only for admins
wppoland_inspect_hook( 'the_content' );
}
} );
Visit any page and scroll to the footer. You’ll see a detailed table showing all functions hooked to the_content.
Inspect multiple hooks
add_action( 'wp_footer', function() {
if ( ! current_user_can( 'manage_options' ) ) return;
$hooks_to_check = array( 'the_content', 'wp_head', 'the_title', 'excerpt_length' );
foreach ( $hooks_to_check as $hook ) {
wppoland_inspect_hook( $hook );
}
} );
Inspect with full details
// 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
}
} );
Understanding the output
The output shows a table with three columns:
Priority column
WordPress executes hooks in priority order (lower numbers first). Default priority is 10.
Common Priorities:
1-9: Early execution (before default)10: Default priority11-99: Late execution (after default)999: Very late (almost last)
Example:
Priority 5: wpautop (adds paragraphs)
Priority 10: do_shortcode (processes shortcodes)
Priority 20: custom_plugin_function (runs last)
Function column
Shows the callback function name:
- String functions:
wpautop,do_shortcode - Class methods:
MyPlugin::process_content - Closures:
Closure(anonymous functions)
Source column
Indicates where the hook was added:
- Core: WordPress core functions
- Plugin: Name of the plugin
- Theme: Name of the theme
Why a hook looks empty when it is not
The most common false negative in hook debugging is reading $wp_filter too early. The array is built up as code runs, so what you see depends entirely on when you look. A callback registered inside a template_redirect handler does not exist during init. A callback added by a plugin that only loads on singular views is absent on the archive you happened to test.
Register your inspection call as late as the problem allows. wp_footer works for front-end template output because everything that touches the_content has already been registered by then. For anything that runs outside the theme, shutdown is the safer choice because it fires on almost every request type, including admin screens and REST responses.
WordPress ships five functions that answer the timing question without any custom code. did_action( 'init' ) returns how many times that action has already completed, which is the fastest way to prove a hook fired at all. current_filter() returns the name of the hook currently executing. doing_action() and doing_filter() answer the same question for a specific name. did_filter() was added in WordPress 6.1 and does for filters what did_action() does for actions. The global $wp_current_filter holds the whole nested stack, which matters because filters routinely fire inside other filters and the innermost one is not always the one you are debugging.
If the hook name is genuinely absent from $wp_filter and did_action() still returns a non-zero count, the hook fires and nothing is attached. That is not a bug, and no amount of re-running the snippet will change it. The problem is somewhere else.
Common debugging scenarios
Scenario 1: Content disappears
Problem: Post content is empty or missing.
Debug:
wppoland_inspect_hook( 'the_content' );
Look for:
- Functions that return empty strings
- Functions with high priority that might override content
- Plugin functions that strip HTML
Scenario 2: Title changes unexpectedly
Problem: Page titles are modified by something.
Debug:
wppoland_inspect_hook( 'the_title' );
Look for:
- SEO plugins modifying titles
- Translation plugins
- Custom title filters
Scenario 3: Styles break
Problem: CSS/JS not loading correctly.
Debug:
wppoland_inspect_hook( 'wp_head' );
wppoland_inspect_hook( 'wp_enqueue_scripts' );
Look for:
- Plugins removing stylesheets
- Conflicting enqueue priorities
- Scripts loading in wrong order
Advanced: Programmatic hook inspection
Check if specific function is hooked
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';
}
Remove specific hook temporarily
// Remove a problematic hook
remove_filter( 'the_content', 'problematic_function', 10 );
// Or remove all hooks from a plugin
global $wp_filter;
if ( isset( $wp_filter['the_content'] ) ) {
foreach ( $wp_filter['the_content']->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $callback ) {
if ( strpos( $callback['function'], 'ProblemPlugin' ) !== false ) {
remove_filter( 'the_content', $callback['function'], $priority );
}
}
}
}
Why remove_filter silently does nothing
remove_filter() and remove_action() return a boolean and never warn. When they return false, nothing happened and your page looks exactly the same, so the failure is silent and easily mistaken for a caching problem. There are four distinct causes. The first two are visible in the inspection table, the other two are not.
Wrong priority. Removal matches on priority as well as callback. If the plugin registered at priority 20 and you remove at the default 10, the call fails. The Priority column exists precisely to stop this. Read it, do not assume 10.
Wrong callback identity. For an object method, WordPress matches on the unique ID built from spl_object_hash() of that exact instance. Constructing a fresh instance of the same class and passing it to remove_filter() produces a different hash and removes nothing. You need the original object, which you can only get by reaching into $wp_filter and pulling $callback['function'][0] out of the callbacks array, or by using a public accessor the plugin exposes.
Removal runs before registration. Hook removal is not declarative. If your code runs on plugins_loaded and the plugin registers on init, you removed a callback that did not exist yet and it is added a moment later. Move your removal to a later hook, or to the same hook at a higher priority number.
The callback is a closure. There is no name to pass and no way to reconstruct the same object, so the standard API cannot reach it at all.
Also check the return value correctly. has_filter() returns false when the callback is not attached, and the integer priority when it is. Priority 0 is a legitimate value and is falsy in PHP, so compare with !== false rather than a plain truthiness test.
Removing a closure by its callback ID
Because the unique ID is regenerated on every request, the only reliable way to detach an anonymous function is to find it in the callbacks array during the same request and unset it by key:
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 ) {
unset( $wp_filter[ $hook_name ]->callbacks[ $priority ][ $id ] );
return true;
}
}
return false;
}
Match on the declaring file returned by Reflection, not on a name you guessed. Two caveats. The function above returns immediately after the unset(); if you adapt it to remove several callbacks in one pass, collect the IDs first and unset them after the loop rather than mutating the array you are iterating. And emptying a priority bucket completely leaves an empty array behind instead of removing the key, which is harmless but makes count() checks on that priority misleading.
Failure modes in the snippets on this page
Debugging code that itself crashes is worse than no debugging code, so it is worth naming the sharp edges in the examples above before you paste them into a client site.
The bulk-removal example calls strpos( $callback['function'], 'ProblemPlugin' ). That works only while function is a string. As soon as the loop reaches an array callback or a closure, PHP 8 throws a TypeError because strpos() no longer accepts an array in a string parameter. Guard every branch with is_string(), is_array() and is_object() before touching the value, exactly the way the main inspection function does.
The inspection function calls ksort( $hook_data->callbacks ) on the live WP_Hook object, which mutates global state during a request you are trying to observe. WP_Hook sorts itself when it needs to, so the sort is not required for correctness. Copy the array into a local variable and sort the copy if you want deterministic display order without side effects.
Reflection has its own edges. new ReflectionFunction() accepts a plain function name or a Closure, but throws ReflectionException on a string in Class::method form, which is why the function_exists() guard matters. ReflectionMethod throws when the method is handled by __call() rather than declared, which is common in plugins that use magic methods for their hook proxies. Wrap both in try/catch, and print the exception message instead of the word Unknown so you can tell a missing method apart from a magic one.
Finally, the Source column detection uses wp-content/plugins and wp-content/themes as substrings. On installations that moved the content directory with WP_CONTENT_DIR, or that run plugins from a symlinked path, every row will be labeled Core. Compare against the real constants WP_PLUGIN_DIR and get_theme_root() instead of a hardcoded path fragment.
Modern alternative: Query monitor plugin
In 2026, the best way to debug hooks is using the Query Monitor plugin by John Blackbourn.
Why query monitor?
- Visual Interface: Clean, searchable table
- File Paths: Shows exact file locations
- Component Names: Identifies plugins/themes
- Performance Data: Shows execution time per hook
- No Code Required: GUI-based debugging
Installation
## Via wp-CLI
wp plugin install query-monitor --activate
## Or download from WordPress.org
Using query monitor
- Install and activate Query Monitor
- Visit any page on your site
- Look for the “QM” icon in the admin bar
- Click “Hooks & Actions”
- Search for your hook name
- See all attached functions with priorities and sources
Query monitor vs. custom snippet
Use Query Monitor when:
- You need a permanent debugging solution
- You want performance metrics
- You’re debugging multiple hooks
- You prefer GUI over code
Use Custom Snippet when:
- You need quick, one-off debugging
- You’re in a development environment
- You want to programmatically check hooks
- Query Monitor isn’t available
Inspecting hooks from WP-CLI
When the site will not render, when the admin bar is unavailable, or when you are on a staging box over SSH, WP-CLI reads the same globals without a browser:
## List every callback attached to the_content, priority first
wp eval 'global $wp_filter; foreach ( $wp_filter["the_content"]->callbacks as $p => $cbs ) { foreach ( $cbs as $id => $cb ) { echo $p . "\t" . $id . "\n"; } }'
## Same thing interactively
wp shell
## Longer scripts belong in a file
wp eval-file inspect-hooks.php
Two behaviors will surprise you. First, WP-CLI bootstraps WordPress but never runs the template stack, so template_redirect, wp_head and wp_footer never fire and anything registered inside them is missing from the dump. Callbacks added on plugins_loaded, after_setup_theme and init are all present, which covers most plugin registrations. Second, WP-CLI runs without a logged-in user unless you pass --user, so capability-gated registrations behave differently from what you saw in the browser.
That same tooling gives you a fast bisect when the inspection table names a plugin but you want proof:
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 and --skip-themes accept a comma-separated list and disable loading for that single command only, so nothing is deactivated for real visitors. Running the same check twice, once normally and once with the suspect skipped, is a definitive answer, unlike reading a reconstructed file path.
Hooks that never reach wp_footer
Printing a table into the footer only works on a request that renders a theme template. Plenty of real hook bugs happen on requests that do not: REST API responses under /wp-json/, admin-ajax.php calls, WP-Cron events, block rendering during a save, and CLI runs. Echoing HTML into any of those either goes nowhere or corrupts a JSON response, which then looks like a second, unrelated bug.
For those contexts, write to the log instead of the page. Set WP_DEBUG and WP_DEBUG_LOG to true in wp-config.php, keep WP_DEBUG_DISPLAY at false so nothing leaks into output, and send the dump through error_log(). Output lands in wp-content/debug.log and you can watch it live with tail -f wp-content/debug.log while you replay the failing request. Under WP-CLI, WP_CLI::log() writes to the terminal instead.
The shutdown action is the right anchor for this style of inspection. It fires at the end of nearly every request type, after all conditional registrations have happened, and it does not care whether a template was involved. Combine it with wp_doing_ajax(), wp_doing_cron() and defined( 'REST_REQUEST' ) so each log line records which kind of request produced it. Without that label, a log full of the_content dumps from mixed request types is unreadable.
Best practices for hook debugging
1. Only debug in development
Never leave debugging code in production:
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
add_action( 'wp_footer', function() {
wppoland_inspect_hook( 'the_content' );
} );
}
2. Restrict to admins
Always check user capabilities:
if ( current_user_can( 'manage_options' ) ) {
// Show debug info
}
3. Use descriptive hook names
When creating custom hooks, use prefixes:
// Good
do_action( 'wppoland_before_content' );
// Bad
do_action( 'before_content' );
4. Document your hooks
/**
* Fires before the main content area
*
* @param string $content The post content
*/
do_action( 'wppoland_before_content', $content );
Troubleshooting common issues
Issue: Hook not showing up
Possible Causes:
- Hook hasn’t fired yet (check hook timing)
- Conditional logic prevents hook from firing
- Hook name is misspelled
Solution:
// Check if hook exists
if ( has_action( 'your_hook_name' ) ) {
echo 'Hook exists';
} else {
echo 'Hook does not exist';
}
Issue: Function not executing
Possible Causes:
- Priority conflict
- Conditional logic
- Hook removed by another plugin
Solution:
// Check current priority
$priority = has_filter( 'the_content', 'your_function' );
echo "Priority: $priority";
Tracing the actual firing order with the all hook
Listing what is attached answers one question. It does not answer the other one: in what order did things actually run on this request. WordPress reserves the hook name all. Every do_action() and apply_filters() call checks for it before running the hook’s own callbacks and dispatches through _wp_call_all_hook(), so a callback attached to all sees every action and filter that fires:
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'] ) ) );
} );
Two rules make this usable. Register it as early as possible, ideally from an MU-plugin, because anything that fires before your callback is attached is invisible. And always filter by prefix before logging, because a single front-end request fires hooks in the hundreds and an unfiltered trace produces a log you will not read. The depth value from $wp_current_filter is what tells you a filter ran nested inside another one, which is the usual explanation for a callback that appears to run twice.
The cost is real: all adds a function call to every hook invocation on the request. Use it to find the ordering, then delete it. It does not belong in a permanent debug MU-plugin.
Verifying that your fix actually worked
Removing a callback and seeing the page look right is not proof. Run through the same checks every time, in this order.
Before you change anything, record the current state so you have something to compare against. Note the exact priority from the inspection table, the callback ID, and the resolved source file. Save the rendered output of one affected URL, logged out, so you can diff it later. Confirm the symptom actually reproduces on a clean request rather than only in your browser session.
After the change, re-run the inspection on the same hook and confirm the row is gone, not just that the page looks different. has_filter( 'the_content', 'callback_name' ) === false is the machine-readable version of the same check and belongs in a WP-CLI one-liner rather than in your eyes. Then confirm the removal did not take anything else with it: the callback count for that priority should drop by exactly one.
Then rule out caching, which produces both false failures and false successes. Flush the object cache with wp cache flush, purge any page cache or CDN layer in front of the site, and reload as a logged-out visitor rather than as the administrator who bypasses most caching. If the host runs OPcache with opcache.validate_timestamps disabled, edits to an MU-plugin are not live until the cache is reset, so a change that seems to have no effect may simply not be running yet.
Finally, check the blast radius. A removal that fixes a single page template can break another one that depended on the same callback. Test at least one singular post, one archive, one admin screen and one REST endpoint. If you removed a filter that other code assumed was present, the failure usually shows up as unescaped or unformatted output somewhere unrelated, not as a PHP error.
When the fix is confirmed, replace the temporary removal with something permanent and documented: a named function in an MU-plugin with a comment recording the hook, the priority, the plugin it targets and the date. Anonymous removals scattered in functions.php are the reason the next person has to repeat this whole investigation.
Summary
Debugging WordPress hooks is essential for understanding plugin conflicts and theme issues. The custom snippet provides detailed information about:
- What functions are hooked
- Their execution priorities
- Which plugins/themes added them
- Source file locations
Key Takeaways:
- Use
$wp_filterglobal to inspect hooks - Query Monitor is the best GUI solution for 2026
- Always restrict debugging to development/admin users
- Understand priorities to fix execution order issues
- Document your custom hooks for maintainability
Whether you use the custom snippet or Query Monitor, understanding WordPress hooks is crucial for professional WordPress development in 2026.







