
Table of Contents
Use explicit named callbacks for every add_action and add_filter so you can remove them later. Set priority deliberately (10 default, lower runs first). Verify with WP-CLI wp hook or has_action(). Never rely on anonymous functions for removable hooks. If output changes as expected after a cache flush and no fatal errors appear in wp-content/debug.log, you wired it correctly.
How it works: the hook registry and callback storage
WordPress stores all actions and filters in the global $wp_filter array, managed by WP_Hook objects since WordPress 4.7. Each hook name maps to a WP_Hook instance holding callbacks bucketed by integer priority. When do_action() or apply_filters() fires, WordPress iterates the buckets from lowest to highest priority and calls each callback with the relevant arguments.
Actions are execution points that let you run code; filters intercept and must return a value. The internal iterator in WP_Hook::apply_filters() merges callbacks per priority, sorts them, and invokes them in order. This is why return values only matter for filters — an action discards whatever your callback returns.
Here is a minimal runnable example you can drop into a mu-plugin at wp-content/mu-plugins/hook-demo.php:
<?php
// Action: runs code, return ignored
add_action( 'init', 'rwp_log_init', 10 );
function rwp_log_init() {
error_log( 'Init fired at ' . time() );
}
// Filter: must return the first argument
add_filter( 'the_title', 'rwp_uppercase_title', 10, 1 );
function rwp_uppercase_title( $title ) {
return strtoupper( $title );
}
Reload any front-end page. Titles render uppercase and wp-content/debug.log records the init call if WP_DEBUG_LOG is true. That confirms both hook types work.
Actions vs filters
Actions (add_action / do_action) are for side effects: sending mail, enqueuing assets, logging. They receive arguments by reference only if declared, and their return value is discarded by do_action(). Filters (add_filter / apply_filters) sit between data production and consumption — the filtered value must be returned or the content is lost.
The critical difference: forgetting to return in a filter silently breaks output. A common pitfall is writing a filter that echoes instead of returning, which injects text at the wrong point in the buffer. Always return $value; as the last statement.
To verify a filter works, use WP-CLI: wp eval 'echo apply_filters("the_title", "test");'. If you see TEST, the filter chain is intact. Roll back by removing the add_filter line and flushing object cache with wp cache flush.
Get a Quote
Email + requirement + budget range — we reply with a quote within 24 hours.
Priority and order
Priority is an integer from PHP_INT_MIN to PHP_INT_MAX; default is 10. Lower numbers execute earlier. Two callbacks on the same priority run in insertion order. Because WP_Hook sorts per priority bucket, you can guarantee ordering by spacing priorities (e.g., 9 before default, 11 after).
Do not use priority 0 or negative values casually — core and major plugins reserve early real estate. For layout work alongside Custom Post Types and Taxonomies: Design Mistakes, a priority of 20 on template_include ensures your override loads after theme defaults.
| Priority | Execution | Typical use |
|---|---|---|
| 5 | Earliest | Core bootstrap overrides |
| 10 | Default | Most plugin and theme code |
| 20 | Late | Post-processing, cleanup |
Verify order with wp hook the_title --fields=callback,priority in WP-CLI 2.11+ (2026). If output shows your callback at the wrong index, change the integer and re-run. Rollback is a one-line edit — no database impact.
Step-by-step Process: removing a hook safely
- Identify the exact callback string or array used in the original
add_actioncall. - Match the priority integer — removal fails silently if it differs.
- Call
remove_action()(orremove_filter()) after the original was added but before the hook fires. - Wrap removal in a later hook if the target is registered early, e.g.
add_action('init', 'rwp_remove', 20). - Confirm with
has_action('hook_name', 'callback')returning false. - Flush transients and object cache with
wp transient delete --all && wp cache flush.
When remove_action runs
remove_action() only works if the callback is already in the $wp_filter registry and has not yet been invoked for that request lifecycle. If you call it before the plugin registers the hook, nothing is removed and you get no warning. If you call it after do_action() fired, it is too late for that request.
The safe pattern is to remove inside a hook that runs later than registration but earlier than execution. For example, a theme adding a filter on after_setup_theme can be removed on init at priority 20:
<?php
add_action( 'init', function() {
remove_filter( 'the_content', 'bad_plugin_filter', 10 );
}, 20 );
Note the closure here is fine because we are adding, not removing, a target by name. Verify with has_filter('the_content', 'bad_plugin_filter') — it must return false. If the site still shows old behaviour, you removed too late; move to an earlier hook like plugins_loaded.
Closures cannot be removed

Anonymous functions (closures) assigned to hooks cannot be targeted by remove_action() because PHP generates a unique internal name per closure instance. Even if you store the closure in a variable and lose reference, there is no string or array handle to pass to the remover.
This is the single most common pitfall in agency code reviews. The fix is trivial: always declare a named function or use a static class method. If you inherit closure-based code, roll back by disabling the mu-plugin file or using a code-level patch — there is no runtime removal.
<?php
// BAD: cannot be removed later
add_action( 'wp_footer', function() { echo 'tracking'; } );
// GOOD: removable by name
add_action( 'wp_footer', 'rwp_footer_tracking' );
function rwp_footer_tracking() { echo 'tracking'; }
// Later: remove_action( 'wp_footer', 'rwp_footer_tracking' );
For teams scaling Vendor Selection for WordPress Membership Sites, enforce named callbacks in your PHPCS standard. Verify by grepping grep -rn "add_.*function *(" wp-content/ and refactoring hits.
Frequently asked questions
Can I remove a hook added by a third-party plugin?
Yes, if the plugin used a named function or static method. Find the exact string in its source, match priority, and remove on a later hook. See the official remove_action reference for signatures.
Why does my remove_action call do nothing?
You either mismatched priority, used the wrong callback identifier, or ran before registration. Check with has_action() and confirm timing relative to the adding code.
Do closures ever work for removable hooks?
No. PHP closures lack a stable name for the registry. Always use named functions or class methods when removal is required.
Action checklist
- Use named functions for every hook you may need to remove.
- Set explicit priority integers instead of relying on 10.
- Always return the first argument in filter callbacks.
- Verify registration with
has_action()/has_filter()and WP-CLIwp hook. - Flush cache and check
debug.logafter every hook change.
Review more engineering deep dives in our technical column index. Need production-grade hook architecture? See our plugin development services for audited, removable code. Talk to our team today.
“, “faq”: [ { “question”: “Can I remove a hook added by a third-party plugin?”, “answer”: “Yes, if the plugin used a named function or static method. Find the exact string in its source, match priority, and remove on a later hook.” }, { “question”: “Why does my remove_action call do nothing?”, “answer”: “You either mismatched priority, used the wrong callback identifier, or ran before registration. Check with has_action() and confirm timing relative to the adding code.” }, { “question”: “Do closures ever work for removable hooks?”, “answer”: “No. PHP closures lack a stable name for the registry. Always use named functions or class methods when removal is required.” } ], “cover”: “A modern flat vector illustration of interlocking gear nodes connected by colored priority lanes on a clean grid background, showing hook callbacks flowing left to right with numbered order badges, in blue and orange tones, no text or logos.