Skip to content
System nominalUTC --:--:--Articles 041

Hook Priority Is a Coordination Mechanism, Not a Tuning Knob

Priority decides who runs when, and two plugins at 10 are arguing without knowing it. How WP_Hook orders callbacks and how to read the argument from your own site.

Martin Kiel · @mkiel

WordPress internals

Topic
WP
Section
WordPress Internals
Published
20 MAY 2026
Reading time
9 MIN
Words
2312W
Technical cover graphic for Hook Priority Is a Coordination Mechanism, Not a Tuning Knob
Generated schematic — WP series, REV 2.6.

A marketplace extension adds a handling fee to the WooCommerce cart. A second extension recalculates the cart total so a loyalty discount applies after fees. Both hook the same filter at the default priority of 10, so the order between them is decided by the order their plugin folders sort on disk. The store tested fine in staging, where the marketplace plugin was installed first. On production the loyalty plugin runs first, the fee is added afterwards, and the fee is never discounted. The totals are wrong by a few cents on every order, and the difference between the two environments is a directory listing.

That bug is not a bug in either plugin. Each one picked a priority that is correct in isolation and means nothing in combination. Priority is the only ordering channel WordPress gives plugin authors, and almost nobody treats it as a shared resource. This is what I mean when I say priority is a coordination mechanism: it is a negotiation, and the other side of the negotiation is a plugin you have never read.

WP_Hook is a nested array, and the nesting is the entire API

Every hook in a request is an instance of WP_Hook, held in the global $wp_filter array, keyed by hook name. Each instance stores its callbacks in a property called callbacks, and that property is an array indexed by priority. Under each priority sits another array of callback entries, each holding the callable and the number of arguments it accepted.

So the structure of a busy hook is roughly this, after two plugins have registered at the default priority and one has registered late:

$wp_filter['woocommerce_cart_calculate_fees']->callbacks

  [10] => [
      [0] => [ 'function' => 'marketplace_add_fee',   'accepted_args' => 1 ],
      [1] => [ 'function' => 'loyalty_recalculate',    'accepted_args' => 1 ],
  ]
  [99] => [
      [0] => [ 'function' => 'myplugin_round_fees',    'accepted_args' => 1 ],
  ]

Two rules follow from that shape, and they explain nearly every ordering puzzle I have been handed.

The inner arrays are appended, never sorted. When a callback is registered at a priority that already exists, it lands at the next free numeric key in that priority's array. WP_Hook::do_action() walks the inner array in key order, so callbacks at the same priority run in the order they were added. Nothing sorts them, and nothing else about them is consulted, not the plugin name, not the function name, not the file path.

Registration order is plugin load order. Plugins load in the order the active_plugins option lists them, then must-use plugins, then whatever the theme adds. That ordering is stable on a given site and effectively arbitrary across sites. If you have ever had a bug that only reproduces on the client's server and never locally, this is one of the usual reasons.

The priority values themselves carry no meaning. 10 is not "normal" and 20 is not "later". The default was set before most of the plugin ecosystem existed and has not moved since, because moving it would reorder every plugin at once. A number matters only relative to the numbers other people chose.

Two plugins at 10 are arguing without knowing it

The tell for a priority collision is a test that passes in one environment and fails in another with the same code, the same WordPress version and the same database dump. When that happens, check what $wp_filter says about the hook in each environment before you read either plugin.

The practice that avoids it is unglamorous. Read a value at 10 and act on it later rather than racing another plugin to produce the same value. Pick numbers that are visibly deliberate: 5 for before the default, 20 for after it, 99 for after anything reasonable. When you genuinely must run after a specific plugin, detect it in code instead of picking a number and hoping.

Removing a callback requires the same priority and the same argument count

remove_action() and remove_filter() do not search by callable. They rebuild the same identity the registration used: hook name, callable, priority and accepted argument count. All four must match, or the removal fails silently and returns false.

// Registration inside somebody else's plugin.
add_filter( 'the_content', 'their_class_callback', 20, 2 );

// Wrong on both counts: default priority, default argument count.
remove_filter( 'the_content', 'their_class_callback' );

// Correct.
remove_filter( 'the_content', 'their_class_callback', 20, 2 );

Object methods are worse, because the callable in the removal has to be the same object instance, not an equivalent one. If the plugin registers from inside a method with array( $this, 'render' ) and your code constructs a new instance of that class to remove it, nothing is removed. You need the instance that registered.

One case here is by design rather than by mistake. If the registration happens in a constructor, a closure, or a hook that has not fired yet, you cannot remove it yet, because the callback does not exist when your code runs. Removal has to land after registration and before the hook fires, which usually means plugins_loaded or later. Sometimes that wait is itself the bug.

do_action and apply_filters are the same machine with different contracts

Both functions end at the same place. They resolve the hook name to a WP_Hook, run its callbacks in priority order, and return the result.

// add_action and add_filter are the same call.
add_action( 'wpsi_after_import', $callback, 10, 2 );
add_filter( 'wpsi_after_import', $callback, 10, 2 );

// Both of these find it.
has_action( 'wpsi_after_import', $callback );   // returns 10
has_filter( 'wpsi_after_import', $callback );   // returns 10

The difference is the contract, and it is worth being strict about because the contract is what other developers depend on. A filter passes a value through: every callback receives the current value as its first argument, and returns a value, and the return value becomes the input to the next callback. An action is a notification: callbacks receive arguments, return values are discarded, and nothing downstream sees them.

Two practical consequences. If you register a callback on a filter and forget to return the value, nothing errors. The next callback in the chain receives null, and whatever was going to render on the page disappears. This is the most common way a plugin breaks a site it was not touching. The other is that has_action() also answers for filters, because both use the same storage.

The contract is what other developers depend on, so be strict about which one you publish. Use do_action() when you are announcing something and do not care what comes back. Use apply_filters() when your function computes a value that another party may need to alter. Publishing an action where a filter belongs locks out every downstream plugin, and that decision is expensive to reverse.

Adding a callback inside an action mutates the iteration you are standing in

Here is the part that catches experienced developers. WP_Hook::do_action() does not take a copy of the callback list before it starts. It walks the live array. So when a callback calls add_filter() or add_action() on the hook that is currently running, it is editing the structure the loop is reading.

Two behaviours follow, and both have been stable for years:

  1. A callback added at a priority that has not been reached yet will run on this pass, because the loop reaches that bucket later and finds the new entry in it.
  2. A callback added at the priority currently being iterated joins the inner array that is being walked, and runs on this pass as well.

Case two is the one that produces duplicate side effects. A handler that conditionally registers a follow-up on the same hook, intending it for a later request, gets it run immediately instead. If the follow-up sends an email or writes a log row, you get two. If it re-enters the handler, you can get a loop, and WP_Hook protects itself against the obvious version of that by tracking a nesting level, which is also the counter behind doing_action().

add_action( 'wpsi_sync_batch', function ( $batch ) {
    // Running on the hook we are about to add to. This fires in the
    // same pass, immediately after this callback returns.
    add_action( 'wpsi_sync_batch', 'wpsi_sync_followup', 20, 1 );

    // Guard for the case where that is not what you wanted.
    if ( doing_action( 'wpsi_sync_batch' ) && $batch->is_followup ) {
        return;
    }
}, 10, 1 );

My rule is simple: never register on the hook you are currently handling. If you need a second pass, register during init or an earlier lifecycle hook, or use a different hook name for the follow-up. The exception is the deliberate one, where you want the late entry to run now, and then you should say so in a comment, because the next reader will assume it runs later.

A debugging recipe that reads the real ordering

You do not need a plugin to answer "who is on this hook and in what order". The global is right there, and reading it tells you the truth about the current request rather than the documented intent.

// wp-content/mu-plugins/hook-dump.php
add_action( 'all', function ( $hook_name ) {
    if ( 'woocommerce_cart_calculate_fees' !== $hook_name ) {
        return;
    }

    global $wp_filter;

    if ( empty( $wp_filter[ $hook_name ]->callbacks ) ) {
        return;
    }

    $lines = array();

    foreach ( $wp_filter[ $hook_name ]->callbacks as $priority => $entries ) {
        foreach ( $entries as $entry ) {
            $callable = $entry['function'];

            if ( is_string( $callable ) ) {
                $label = $callable;
            } elseif ( is_array( $callable ) ) {
                $class = is_object( $callable[0] ) ? get_class( $callable[0] ) : $callable[0];
                $label = $class . '::' . $callable[1];
            } elseif ( $callable instanceof Closure ) {
                $label = 'Closure';
            } else {
                $label = gettype( $callable );
            }

            $lines[] = sprintf( '%s  %s  (args: %d)', $priority, $label, $entry['accepted_args'] );
        }
    }

    // error_log writes one line per call; join with a pipe so the
    // order in the log matches the order WordPress will run.
    error_log( 'HOOK ORDER ' . $hook_name . ' => ' . implode( ' | ', $lines ) );
}, 10, 1 );

That dump describes the state of the hook at the moment it starts, not partway through, because all runs before the hook's own callbacks. So it will not show you a callback that a running handler just added. Use it to read the initial ordering, and reach for the shell one-liner below when you suspect something is registering late.

For a quick look without editing files, the shell is faster. One command answers both questions:

# Who is on the hook, with priority and argument count per callback.
wp eval 'global $wp_filter; $h = "woocommerce_cart_calculate_fees";
foreach ( $wp_filter[$h]->callbacks as $p => $e ) {
  foreach ( $e as $c ) { $f = $c["function"];
    echo $p, "\t", is_array($f) ? get_class($f[0])."::".$f[1] : ( is_object($f) ? "Closure" : $f ),
      "\t(args: ", $c["accepted_args"], ")\n"; } }'

Run that in staging and in production and diff the two lists. The comparison is usually the answer, and it takes about a minute. It also removes a class of guessing, because the output is the running configuration rather than somebody's description of it.

Cost warning

Dumping $wp_filter on a store with a page builder and a handful of marketing plugins is verbose: the global holds hundreds of hook names, and var_export() on it will exhaust your PHP memory limit long before it finishes printing. Filter to one hook name, as above, and never print_r() the whole global on a live request.

The limit: late priorities make ordering unexplainable

When two plugins collide at 10, the tempting fix is to move your own callback to a number so high that nothing can plausibly follow it. It works, and I have done it. It is also where the technique stops being defensible.

A priority communicates something only when other people can read it as a position: 5 is before the default, 10 is the default, 20 is after it. A callback at PHP_INT_MAX does not say "after". It says the author gave up on ordering and claimed the last word. Two plugins that both claim the last word are back to alphabetical order, except now neither sits at the priority a third-party developer would check first. The rare honest case for the end of the chain is a value that must win, such as a final sanitisation pass or a price floor, and that is a claim about correctness rather than timing.

The other limit is the one I cannot engineer away. On a site you do not control, at a hook you did not define, with a plugin whose source you have not read, the ordering is decided by configuration you can only observe. Inspecting $wp_filter tells you what is true on the machine in front of you, at that moment, with the current plugin set. It does not tell you what will be true after the client adds one more extension. The only durable fix is to stop depending on relative position: publish your own hook, let other code hang off it explicitly, and leave the default priority alone.

What to do on Monday

  1. For every hook your plugin touches, write down the priority you chose and one clause explaining why. If you cannot explain it, it is 10 by accident.
  2. Search for any callback you register on a hook your own code also fires. Move those registrations to a lifecycle hook that runs earlier.
  3. Check each remove_action() and remove_filter() call against the registration it targets: priority and argument count, both.
  4. Add the all-hook dump to a must-use plugin on staging and commit the output next to the bug report. A list of priorities ends the argument faster than a description of it.
  5. Where two plugins genuinely have to be ordered, replace the shared priority with an explicit chain: fire your own action after your work, and let the other party hook that.

Martin Kiel · @mkiel

WordPress internals

Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.

Continue

Related in WordPress Internals