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.
What actions and filters are in WordPress
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 run code at a specific point and return nothing (e.g.,
init,wp_head,wp_footer) - Filters receive a value, modify it and must return it (e.g.,
the_title,the_content)
Both live in the same registry. add_action() is a thin wrapper that calls add_filter(), so one inspection routine covers both.
When debugging, you need to see:
- What functions are hooked
- Their priorities (execution order)
- Which plugin/theme added them
- The callback function names
How WordPress stores hook callbacks in $wp_filter and WP_Hook
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() in wp-includes/plugin.php. 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 with ::. For an object method or a closure it is derived from the identity of that object: spl_object_hash() up to WordPress 7.0, and spl_object_id() since 7.1, where closure IDs also carry an spl_object_id: prefix (added in 7.1.1 so the key is never cast to an integer). Either way the ID describes one object in one request, so it cannot be hardcoded. That is why you cannot remove a closure by writing it again, and why an object method can only be removed using the same instance that registered it. Since 6.9 the same function returns null for a malformed array callback, and add_filter() then silently registers nothing.
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.
Get a list of all hooked functions in PHP
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 ' . 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 detailsHow to print hooked functions in the footer
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.
How to inspect several hooks at once
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 );
}
} );How to show the file and line of each 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
}
} );How to read hook priority and callback source
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: the callbacks WordPress core itself attaches to the_content, as registered in wp-includes/default-filters.php and 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_smiliesCallbacks that share a priority run in the order they were added. That is documented on WP_Hook::add_filter(), and it is why five core filters can sit at 10 without conflicting. A plugin callback at 10 that was added after wpautop sees content that already has paragraph tags.
Function column
Shows the callback function name:
- String functions:
wpautop,do_shortcode - Class methods:
MyPlugin::process_content - Closures:
Closure(anonymous functions) - Invokable objects:
ClassName::__invoke
Source column
Indicates where the callback is declared. WordPress does not store this, so the snippet reconstructs it from the file Reflection reports:
- Core: a file under
wp-includesorwp-admin - Plugin or MU plugin: the folder name under
WP_PLUGIN_DIRorWPMU_PLUGIN_DIR - Theme: the theme folder under
get_theme_root() - Other: anything else, for example a drop-in such as
object-cache.php, with the full path - Unknown: an internal PHP function, or the Reflection error message
The declaring file is not always the registering file. A plugin that attaches a core function such as wp_strip_all_tags to a filter shows up as Core, because Reflection reports where the function is defined, not who called add_filter().
Why $wp_filter shows no callbacks for a hook
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 fired so far; the counter in $wp_actions is incremented when do_action() starts, before the callbacks run, so it is already 1 inside an init callback. It 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.
Find which plugin changes the content or title
Which filter empties the post content
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
Which filter changes the page title
Problem: Page titles are modified by something.
Debug:
wppoland_inspect_hook( 'the_title' );Look for:
- SEO plugins modifying titles
- Translation plugins
- Custom title filters
Why CSS or JS files stop loading correctly
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
Check or remove a hooked function in PHP
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';
}How to remove a hook temporarily
// 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 );
}
}
}
}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 the identity of that exact instance (spl_object_hash() up to 7.0, spl_object_id() since 7.1). 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 writing the same function again creates a new object with a new ID. The standard API can only reach it if you hand it the original closure object, which the next section pulls out of $wp_filter.
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. Since WordPress 6.9, has_filter() and has_action() accept a third $priority argument; when you pass both a callback and a priority, the return value is a plain boolean for that exact priority, which is the cleanest pre-check before a remove_filter() call.
How to remove an anonymous function from a hook
Because the unique ID is regenerated on every request, the only reliable way to detach an anonymous function is to find the closure object in the callbacks array during the same request and hand that same object back to remove_filter(). WordPress builds the ID from the object’s identity, so the object you pulled out produces exactly the key it was stored under:
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;
}Match on the declaring file returned by Reflection, not on a name you guessed. Going through remove_filter() instead of calling unset() on the callbacks array matters. WP_Hook keeps a separate list of priorities (the protected $priorities property, added in WordPress 6.4) and adjusts any iteration that is currently running; WP_Hook::remove_filter() updates both when a priority bucket becomes empty. A raw unset() updates neither, so an emptied bucket stays behind as an empty array and a removal during a running hook can leave the iteration state out of step. Call the function late enough that the closure is already registered, and early enough that the hook has not fired yet.
Hook debugging snippet errors on PHP 8
Debugging code that itself crashes is worse than no debugging code. Earlier revisions of this snippet, including the one this article used to publish, break on PHP 8 in four places, and the code above avoids each of them.
strpos() on a callback. A loop that calls strpos( $callback['function'], 'ProblemPlugin' ) works only while function is a string. As soon as it reaches an array callback or a closure, PHP 8 throws a TypeError, because internal functions no longer accept an array or object where a string is expected. Resolve a name first with is_string(), is_array() and is_object(), then compare, the way the bulk-removal example does.
ksort() on the live object. Sorting $wp_filter[ $hook ]->callbacks in place mutates global state during the request you are trying to observe. WP_Hook::add_filter() already keeps the buckets sorted by calling ksort( $this->callbacks, SORT_NUMERIC ) whenever a new priority appears, so the sort adds nothing for correctness. The inspection function sorts a local copy.
Reflection exceptions. new ReflectionFunction() accepts a plain function name or a Closure and throws ReflectionException on a string in Class::method form. ReflectionMethod throws when the method is handled by __call() rather than declared, which is common in plugins that use magic methods for hook proxies. On PHP 8.4, passing a single Class::method string to new ReflectionMethod() also raises a deprecation notice that recommends ReflectionMethod::createFromMethodName(); splitting the string and passing two arguments works on every PHP 8 version. The function wraps all of it in one try/catch and prints the exception message, so a missing method and a magic one are distinguishable.
Hardcoded paths. Detecting the source with the substrings wp-content/plugins and wp-content/themes mislabels every row on sites that moved the content directory with WP_CONTENT_DIR, and it never recognises must-use plugins. The function compares against WP_PLUGIN_DIR, WPMU_PLUGIN_DIR and get_theme_root() after wp_normalize_path(), which also makes Windows paths comparable. One case remains: Reflection reports the resolved path of a symlinked plugin, so such a callback lands in the Other row with its real path printed.
List hooks with the Query Monitor plugin
For a visual list, the usual tool is the Query Monitor plugin by John Blackbourn, free on WordPress.org. Its readme describes it as “the developer tools panel for WordPress and WooCommerce” and lists hooks and actions among the things it debugs.
Why use Query Monitor for hooks
- Visual interface: one table per request, filterable
- File paths: each callback links to its file and line
- Component names: every row names the plugin, theme or core component responsible
- No code required: nothing to paste into
functions.php
The hooks panel does not measure execution time per hook or per callback. Its columns are the hook name, the type (action or filter, when all hooks are shown), the priority, the callback and the component. For timing, Query Monitor has separate panels, and a profiler is the right tool for per-callback cost.
Installation
# Via WP-CLI
wp plugin install query-monitor --activate
# Or download it from WordPress.orgHow to find a hook in Query Monitor
- Install and activate Query Monitor
- Visit any page on your site while logged in as an administrator
- Open the Query Monitor menu in the admin toolbar, which shows an overview of the current page
- Choose the “Hooks & Actions” panel
- Filter by hook name or by component
- Read the attached callbacks with their priority and component
One detail decides whether this works for the_content. By default the hooks collector lists only the names in $wp_actions, which means actions that fired at least once on the request. Filters such as the_content, the_title or excerpt_length are not in that list. To include them, add this to wp-config.php on a development site:
define( 'QM_SHOW_ALL_HOOKS', true );With the constant set, the collector reads every key of $wp_filter instead, the panel is renamed “Hooks, Actions, & Filters”, and a type column tells actions and filters apart. A second constant, QM_HIDE_CORE_ACTIONS, hides callbacks that belong to core, which shortens the table when you only care about plugins. Query Monitor also flags the special all hook: if anything is attached to it, the panel prints the warning “The all action is extremely resource intensive. Try to avoid using it.”
By default Query Monitor shows its output only to administrators on single sites and to super admins on multisite, so it is safe to leave active on a staging copy.
When to use Query Monitor or a custom snippet
Use Query Monitor when:
- You need a permanent debugging solution
- You also want database queries, HTTP calls and PHP errors for the same request
- 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
How to list WordPress hooks with 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.phpTwo 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. Third, wp eval runs your code inside a method, so the command documentation itself warns that globals such as $wp_filter must be declared with global first, which the one-liner does. The documentation also lists a --hook=<hook> option that runs the code after a given hook has fired, but marks it as not yet part of a stable WP-CLI release; it needs the nightly build (wp cli update --nightly).
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.
How to debug hooks in REST, AJAX and cron requests
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 debugging and naming hooks
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 );Hook not firing or function not executing
Why my hook is not showing up
Possible Causes:
- Hook hasn’t fired yet (check hook timing)
- Conditional logic prevents hook from firing
- Hook name is misspelled
Solution:
// 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)';Why my hooked function is not running
Possible Causes:
- Priority conflict
- Conditional logic
- Hook removed by another plugin
Solution:
// 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;How to log the WordPress hook firing order
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.
How to verify a hook removal 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
The custom snippet shows, for any hook:
- What functions are hooked
- Their execution priorities
- Which plugin, theme or core file declares them
- Source file locations
Key takeaways:
- Read
$wp_filter[ $hook ]->callbacks, the public property ofWP_Hook, and sort a copy, never the live object - Query Monitor lists filters such as
the_contentonly whenQM_SHOW_ALL_HOOKSis defined - Remove callbacks with
remove_filter()and the exact priority, using the original object for methods and closures - Use
did_action(),current_filter()and theallhook for timing and order questions - Restrict debugging output to development and to administrators







