Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 04 / 06 25 MIN

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.

ActionFilter
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.

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.

FunctionQuestion it answersValue 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.

Lab 25 minutes

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.