Hooks and the data layer
Actions, Filters and Priority in WordPress
How the hook system decides which callbacks run, why removal needs the original priority and argument count, and how to dump every callback on a hook.
You write a callback, it runs, and then one day it does not. Nothing throws. There is
no notice in the log. Some other plugin, or a newer version of your own, changed the
priority it registers at, and your remove_action() call has been returning
false ever since, quietly leaving both callbacks registered and both of them
running.
The hook system itself is small. Two functions write into one global array and one class walks that array. Almost every hook bug is a question about identity and time: is this the same callback I registered, at the same priority, and has the part of the list that contains it already gone past? Once you can answer those two questions from a running site, hooks stop being mysterious.
Actions and filters are the same machinery with different obligations
Both are registered with add_filter(), stored in the same
$wp_filter global, and executed by the same WP_Hook object. There
is no separate action registry. add_action() and add_filter() are
the same function registered under two names, and has_action() is an
alias for has_filter(): ask either one about a filter name and you get an
answer.
The difference is the contract at the call site, and it is about what your return value means.
| Action | Filter | |
|---|---|---|
| Fired by | do_action() |
apply_filters() |
| What your callback gets | The arguments core passed, on their way to nowhere | The value first, then the extra arguments |
| What your return value does | Nothing. Actions discard it | Becomes the value the next callback receives |
| What you must return | Anything, or nothing | The filtered value, always, even when you changed nothing |
| Register with | add_action(), remove_action() |
add_filter(), remove_filter() |
The mistake this table prevents is a filter callback that returns nothing. A callback
that prints something and falls off the end returns null, and the next
callback in line receives null where a post object used to be. The site does
not warn you; it renders an empty page or a fatal error two plugins later. In an action,
returning nothing is correct and normal.
// Correct for an action: the return value is discarded.
add_action( 'wp_footer', static function (): void {
echo '<!-- built by WPSifero Notes -->';
} );
// Wrong for a filter: this wipes the content for every later callback.
add_filter( 'the_content', static function ( string $content ): void {
echo '<p>Read this first.</p>';
} );
// The version that keeps the rest of the pipeline working.
add_filter( 'the_content', static function ( string $content ): string {
return '<p>Read this first.</p>' . $content;
} );
Core calls the two differently in one more respect that matters. do_action()
marks the hook as an action for the duration of the call, and inside that mode
WP_Hook never overwrites the argument list with a return value. That is how
do_action( 'save_post', $post_id, $post, $update ) can pass the same two
arguments to fourteen callbacks without any of them returning anything.
A callback can only be removed by the key it was registered under
Registration builds an identity string from the hook name, the callback, and the priority. The argument count is not part of it. Two registrations of the same function at the same priority are the same entry as far as the array is concerned, so the second one overwrites the first rather than running twice. Removal looks up the same string, which gives the rule you need to remember:
remove_action( $hook, $callback, $priority );
^ ^ ^
| | must match what add_action() used
| must be the same callable shape
the exact hook name, spelled the same way
Both the priority and the callable shape have to line up. A method callback registered
as array( self::class, 'append_footer' ) is not the same entry as
array( $this, 'append_footer' ), because one names a class and the other names
an instance. On the other hand, $accepted_args can differ freely: a callback
registered with three accepted arguments and one registered with one, at the same priority,
are the same entry, because the count never enters the identity.
The failure is silent in the direction that hurts. remove_action() returns
false when it found nothing, but almost nobody checks, so the code reads as
though it unregistered something. Here is the same removal attempted twice against a
callback registered at priority 20, run on a local WordPress 7.0.7 install:
add_action( 'init', array( Bootstrap::class, 'boot' ) );
final class Bootstrap {
public const PRIORITY = 20;
public static function boot(): void {
// Four arguments: hook, callback, priority, accepted_args.
add_filter( 'the_content', array( self::class, 'append_footer' ), self::PRIORITY, 1 );
}
}
// Later, in another plugin that wants the footer gone:
remove_filter( 'the_content', array( Bootstrap::class, 'append_footer' ), 10 );
// Returns false. The callback is still there.
before any removal: 20
after remove_filter() at priority 10: 20
after remove_filter() at priority 20: false
Notice what has_filter() returns when it finds the callback: the priority,
not true. Its documented return type is a boolean or an integer priority, so
write false !== has_filter( ... ) when you test it. The usual fix is to stop
scattering the priority through two files. Declare it once as a class constant, register
with the constant and remove with the constant, and the two cannot drift.
There is a second, blunter removal to know about. remove_all_actions()
takes a hook name and an optional priority and empties it. Calling it with no priority on a
hook you do not own is how one plugin breaks five others, and nothing in the log records
that it happened.
Registering a callback while its own hook is running is legal, and the result surprises people
WP_Hook snapshots the list of priorities when it starts, and walks that
snapshot with an internal array pointer. The list of callbacks at each priority is read
fresh from the live array at the moment that priority is reached. So the answer to
"what happens if I add a callback during the run" depends on which priority you pick.
add_action( 'wpsifero_late_hook', static function (): void {
echo "A registered at 10\n";
add_action( 'wpsifero_late_hook', static function (): void {
echo "B added during the run at 30\n";
}, 30 );
add_action( 'wpsifero_late_hook', static function (): void {
echo "C added during the run at 10\n";
}, 10 );
}, 10 );
add_action( 'wpsifero_late_hook', static function (): void {
echo "D registered at 10 before the run\n";
}, 10 );
do_action( 'wpsifero_late_hook' );
A registered at 10
D registered at 10 before the run
B added during the run at 30
Three things happened at once there. C never ran in that pass, even though it
was added at the priority currently executing, because PHP had already taken its copy of the
callback list for the foreach loop. B ran, because 30 had not been
reached and the priority snapshot is patched when a new priority lands ahead of the cursor.
And both B and C are still registered afterwards.
The practical rule: adding at a later priority during the run works, adding at the
current or an earlier priority registers something that fires the next time the hook runs,
and neither case is worth designing around. Say ordering with the priority at registration
time. Reach for the mid-run add_action() only for genuinely conditional work,
such as registering a cleanup handler the first time a specific condition appears.
Removing during the run does not renumber anything. A callback removed at a priority that has not been reached yet will not run, and a callback removed while its own priority is executing still runs if it is the one being called. The snapshot protects you from a crash, not from a stale plan.
current_filter, doing_action and did_action answer three different questions
These three functions get used interchangeably in code reviews and they are not interchangeable. All of them read the same two globals, and all of them are cheap.
| Function | Question it answers | Value outside a hook |
|---|---|---|
current_filter() |
Which hook is running right now, innermost first | false |
doing_action( $hook ) |
Is $hook somewhere on the stack of running hooks |
false |
did_action( $hook ) |
How many times $hook has started, including the running one |
An integer, possibly zero |
add_action( 'wpsifero_state_hook', static function (): void {
printf(
"current_filter=%s current_action=%s doing_action=%s did_action=%d\n",
current_filter(),
current_action(),
var_export( doing_action( 'wpsifero_state_hook' ), true ),
did_action( 'wpsifero_state_hook' )
);
}, 10 );
do_action( 'wpsifero_state_hook' );
current_filter=wpsifero_state_hook current_action=wpsifero_state_hook doing_action=true did_action=1
did_action() is already 1 inside the first callback, because the counter is
incremented before the callbacks run. That makes it a re-entry guard: an action that calls
itself can test did_action() > 1 and stop. It is not a completion flag.
current_filter() is a stack, not a variable, and that is the part people
miss. If your callback on save_post runs a nested do_action(),
then inside that nested hook current_filter() returns the nested name, and once
it returns, the value goes back to save_post. Test it at the top of a callback,
not three function calls deeper where the answer may belong to somebody else.
Dump the hook and you will stop guessing
Every callback a hook will run is sitting in a public property. $wp_filter
maps hook names to WP_Hook objects, each with a public $callbacks
array of priority to callback list. Fifty lines turn that into an answer, and this is the
single most useful debugging tool in plugin work.
function wpsifero_hook_report( string $hook ): string {
global $wp_filter;
if ( empty( $wp_filter[ $hook ] ) ) {
return $hook . ': nothing registered';
}
$lines = array( $hook . ':' );
foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $callback ) {
$fn = $callback['function'];
$args = $callback['accepted_args'];
if ( is_string( $fn ) ) {
$name = $fn;
$ref = new ReflectionFunction( $fn );
} elseif ( $fn instanceof Closure ) {
$name = 'closure';
$ref = new ReflectionFunction( $fn );
} elseif ( is_array( $fn ) ) {
$name = ( is_object( $fn[0] ) ? get_class( $fn[0] ) : $fn[0] ) . '::' . $fn[1];
$ref = new ReflectionMethod( $fn[0], $fn[1] );
} else {
$name = get_debug_type( $fn );
$ref = new ReflectionMethod( $fn, '__invoke' );
}
$lines[] = sprintf(
' %4d %-46s accepted_args=%d %s:%d',
$priority,
$name,
$args,
str_replace( ABSPATH, '', $ref->getFileName() ),
$ref->getStartLine()
);
}
}
return implode( "\n", $lines );
}
Run against a stock install on a real request, a call to
wpsifero_hook_report( 'the_content' ) after init prints fifteen
callbacks on a stock install, most of them core's, plus whatever your plugin and the theme
added. Two details in that list are worth reading closely.
the_content:
5 Walker_Page::walk accepted_args=3 wp-includes/class-wp-walker.php:194
8 apply_block_hooks_to_content_from_post_object accepted_args=1 wp-includes/blocks.php:1214
8 WP_Embed::run_shortcode accepted_args=1 wp-includes/class-wp-embed.php:62
8 WP_Embed::autoembed accepted_args=1 wp-includes/class-wp-embed.php:442
9 do_blocks accepted_args=1 wp-includes/blocks.php:2496
10 wptexturize accepted_args=1 wp-includes/formatting.php:37
10 wpautop accepted_args=1 wp-includes/formatting.php:446
10 shortcode_unautop accepted_args=1 wp-includes/shortcodes.php:824
10 prepend_attachment accepted_args=1 wp-includes/post-template.php:1726
10 wp_replace_insecure_home_url accepted_args=1 wp-includes/https-migration.php:51
11 capital_P_dangit accepted_args=1 wp-includes/formatting.php:5698
11 do_shortcode accepted_args=1 wp-includes/shortcodes.php:243
12 wp_filter_content_tags accepted_args=1 wp-includes/media.php:1906
12 wptexturize accepted_args=1 wp-includes/formatting.php:37
20 convert_smilies accepted_args=1 wp-includes/formatting.php:3483
wptexturize appears twice, at 10 and at 12. Core registered it twice on
purpose: the second pass runs after shortcodes and block output exist, so curly quotes reach
text that was generated late. A duplicate is not always a bug. The second detail is
accepted_args, which is the maximum your callback will accept rather than the
number the hook will hand it. the_content passes one argument, so a callback
registered with accepted_args=3 still receives one. Touch a second argument
that is not there and PHP 8 throws, with the stack trace pointing at your callback rather
than at the hook that under-supplied it.
Very late priorities make ordering unexplainable, and that is the real limit
Priorities are integers, they can be negative or enormous, and nothing stops you from
registering at PHP_INT_MAX to guarantee you run last. Doing that trades a
readable hook for a promise you cannot keep.
Late priorities break down in three ways. Their ordering relative to each other becomes guesswork, because 9999 and 10000 carry no meaning. Their work lands after everything else has shaped the value, so a filter that needed to see the final content runs after the block renderer, the shortcode pass and the escaping. And every one of them still pays a full pass over the same string on every request.
What to do instead: pick a small number of meaningful bands and stay inside them. Core uses 0 to 20 for most things, so 5 meaning "before core", 10 meaning "with everyone else" and 20 meaning "after core has finished" is enough vocabulary. When you need to run after a specific named plugin, hook to something that plugin fires rather than racing it by number. When you cannot make the ordering explainable, change the design instead of the integer.
Install the hook report function as
wp-content/mu-plugins/00-hook-report.php and print
wpsifero_hook_report( 'the_content' ) on wp_loaded for a real
front-end page view, then for save_post during a post save from the admin.
Now build the experiment. In a plugin, register the same callable on
wpsifero_probe at priority 30, at priority 10, at priority 10 a second
time, and once more as a different callable at priority 10. Fire the hook, then answer
four questions from the output: how many times did the callback run, in what order, does
remove_action() with the default priority remove the entry registered at
30, and what does has_filter() return before and after a removal that
succeeds?
Finally, register a callback at priority 10 that adds a second callback to the same hook at priority 10, and predict whether it runs in the same pass. Then check.
Show the solution and the reasoning
The callback registered twice at priority 10 runs once, not twice. The identity string
that add_filter() computes from the hook name, the callback and the priority
is the array key, so the second registration overwrites the first. The report confirms it:
the priority-10 block has three entries for four add_action() calls. The
different callable is a separate key, which is the mechanism you exploit when you want two
behaviours from one function on one hook.
The order is priority first, registration order within a priority. Nothing sorts callbacks by name, file or load order, so two plugins at priority 10 run in the order they registered, which for ordinary plugins is the stored active plugin order.
remove_action( 'wpsifero_probe', $callback ) does not remove the entry at
priority 30. The default priority for both functions is 10, and the identity string
includes the priority, so removing at 10 searches the priority-10 list and finds nothing.
The call returns false. Either pass 30, or store the priority in a constant
and use it in both places.
final class Probe {
public const HOOK = 'wpsifero_probe';
public const PRIORITY = 30;
public static function register(): void {
add_action( self::HOOK, array( self::class, 'run' ), self::PRIORITY, 1 );
}
public static function unregister(): void {
remove_action( self::HOOK, array( self::class, 'run' ), self::PRIORITY );
}
public static function run(): void {
// ...
}
}
has_filter() returns the integer priority when the callback is present and
false when it is not. That is the one return value in this API where a truthy
check is safe and a true comparison is wrong.
The callback that adds another callback at its own priority does not run it in the same pass. PHP copied the callback list into the loop before calling anything, and the new entry is added to the stored array, not to that copy. It will run the next time the hook fires, and only if your plugin is still loaded. Registering at a later priority would run it immediately, but only because the priority snapshot is updated when a new priority arrives ahead of the cursor, which is a subtlety of the implementation rather than a feature you should depend on.
One thing this lab does not give you: the report shows accepted argument counts, but
nothing in the data structure records how many arguments the hook will actually pass. That
number lives at the do_action() or apply_filters() call site,
usually in core or in a plugin you do not control. Find the call site, or log the argument
count from inside your callback.
Progress
Mark this lesson done and it stays marked in this browser.