Dissecting WordPress: Hooks: Filters and Actions

Many people tend to take WordPress hooks for granted (or loathe them). Hooks are a powerful way to change the behavior of WordPress fairly drastically. Hooks are built into WordPress. Without hooks, you’d have to fork the original code and modify it to change it’s behavior. They allow you to literally hook into core functions, change and override the output of functions or disable functionality entirely. But what, exactly, are hooks?

The public hook API is pretty straightforward: add_action(), add_filter(), remove_action(), remove_filter(), do_action(), and apply_filters() (along with some introspection like has_filter()). These global functions work together to provide the basic building block of every single theme and plugin. Underneath the covers, they interact with a WP_Hook class that drives everything.

What is the WP_Hook class? At it’s core, it’s a FIFO (First In, First Out) priority queue. Put callbacks in the queue, and get them out again, FIFO, by their priority. However, the implementation itself is highly idiomatic, O(n log n) (worst case) when adding new items, and actually faster (wall clock time) than SPL priority queue, at least when I measured it a few years ago. Visiting items are O(n).

Adding Items

The data structures utilized are part of what makes it so fast. Let’s take a look at how items are added to the queue, which is actually a little bit more complicated than you’d think.

Actions in the WP_Hook class are mostly special wrappers around filters.

Let’s take a look at add_filter which is also called when adding actions:

	public function add_filter( $hook_name, $callback, $priority, $accepted_args ) {
		$idx = _wp_filter_build_unique_id( $hook_name, $callback, $priority );

		$priority_existed = isset( $this->callbacks[ $priority ] );

		$this->callbacks[ $priority ][ $idx ] = array(
			'function'      => $callback,
			'accepted_args' => $accepted_args,
		);

		// If we're adding a new priority to the list, put them back in sorted order.
		if ( ! $priority_existed && count( $this->callbacks ) > 1 ) {
			ksort( $this->callbacks, SORT_NUMERIC );
		}

		if ( $this->nesting_level > 0 ) {
			$this->resort_active_iterations( $priority, $priority_existed );
		}
	}

The first thing we’ll notice is that it essentially does a few things:

  1. Creates a unique id called idx for the callback,
  2. Adds the callback to the callbacks property,
  3. And resorts callbacks property.

Let’s ignore WP_Hook::resort_active_iterations() for now. Notice that it’s guarded by an if statement that we’ll get to later.

So, what is this unique id?

// from plugin.php
function _wp_filter_build_unique_id( $hook_name, $callback, $priority ) {
	if ( is_string( $callback ) ) {
		return $callback;
	}

	if ( is_object( $callback ) ) {
		// Closures are currently implemented as objects.
		$callback = array( $callback, '' );
	} else {
		$callback = (array) $callback;
	}

	if ( is_object( $callback[0] ) ) {
		// Object class calling.
		return spl_object_hash( $callback[0] ) . $callback[1];
	} elseif ( is_string( $callback[0] ) ) {
		// Static calling.
		return $callback[0] . '::' . $callback[1];
	}
}

The idx is basically being normalized into a string that represents the callback. See, in PHP, many different things represent a callback. It can be a string like "_return_true", an actual Closure, or an array representing an object and it’s method like [ $my_obj, 'my_method' ].

What this means, is that if we have two [ $my_obj, 'my_method' ] arrays and both $my_obj points to the same instance, then we’ll end up with the same string. Something like "00000000022d1bd3000000001c1f01bemy_method". The last case even protects against the edge case where we have a class called 00000000022d1bd3000000001c1f01be (ignoring the fact that a class name can’t start with a number) and call a static method with it by inserting the :: in the id.

So, now we know how the id is created, we can look at the rest of the function:

$priority_existed = isset( $this->callbacks[ $priority ] );

priority_existed will be true if ::add_filter has been called with the same priority. As seen in the next block of code:

		$this->callbacks[ $priority ][ $idx ] = array(
			'function'      => $callback,
			'accepted_args' => $accepted_args,
		);

As we can see, we’ll create a multi-dimensional array using priority and idx as the keys and store the callback and the number of arguments the callback accepts.

If the priority didn’t exist, we’ll ask PHP to resort the array and put the callbacks array back into priority order, only if the array has more than one priority.

		// If we're adding a new priority to the list, put them back in sorted order.
		if ( ! $priority_existed && count( $this->callbacks ) > 1 ) {
			ksort( $this->callbacks, SORT_NUMERIC );
		}

At first glance, it may seem like there’s a performance penalty to “count” the array every time we add to it, however, the performance of count() is O(1). There is a small performance penalty to sort the array, though in practice, most hooks tend to use the default of 10 for priority.

So that’s how we add to the priority queue. Now, how do we take them off?

Applying A Filter

Unlike a traditional priority queue, there’s no way to actually pop items off the queue. We may need to run through the same queue multiple times without losing items on the queue, or even add/remove items from the queue as we’re processing it. These requirements alone make using a “real” priority queue prohibitive. We’re going to visit these cases later, but first, let’s take a look at the implementation:

	public function apply_filters( $value, $args ) {
		if ( ! $this->callbacks ) {
			return $value;
		}

		$nesting_level = $this->nesting_level++;

		$this->iterations[ $nesting_level ] = array_keys( $this->callbacks );
		$num_args                           = count( $args );

		do {
			$this->current_priority[ $nesting_level ] = current( $this->iterations[ $nesting_level ] );
			$priority                                 = $this->current_priority[ $nesting_level ];

			foreach ( $this->callbacks[ $priority ] as $the_ ) {
				if ( ! $this->doing_action ) {
					$args[0] = $value;
				}

				// Avoid the array_slice() if possible.
				if ( 0 == $the_['accepted_args'] ) {
					$value = call_user_func( $the_['function'] );
				} elseif ( $the_['accepted_args'] >= $num_args ) {
					$value = call_user_func_array( $the_['function'], $args );
				} else {
					$value = call_user_func_array( $the_['function'], array_slice( $args, 0, (int) $the_['accepted_args'] ) );
				}
			}
		} while ( false !== next( $this->iterations[ $nesting_level ] ) );

		unset( $this->iterations[ $nesting_level ] );
		unset( $this->current_priority[ $nesting_level ] );

		$this->nesting_level--;

		return $value;
	}

This function takes a value, some arguments, and returns a new value. You may use it like apply_filters( 'automatic_updater_disabled', AUTOMATIC_UPDATER_DISABLED ?? false ); to see if automatic updates have been disabled.

The First Iteration

Let’s take a look how this function operates on a simple filter with no nesting. For example, something like this:

$hook = new WP_Hook();
$hook->add_filter( 'test', fn( $val ) => $val + 1, 10, 1 );
var_dump( $hook->apply_filters( 0, [0] ) );

So, upon entering this function, we will note that we store and then increment the current nesting level.

		$nesting_level = $this->nesting_level++;

		$this->iterations[ $nesting_level ] = array_keys( $this->callbacks );
		$num_args                           = count( $args );

Then we set the iterations array to the keys of the callbacks. Sooo, iterations will look something like this:

$iterations[0] = [ 10 ];

Now, we enter a huge do loop. If you haven’t seen one of these in awhile (pardon the pun), it means we’ll always “do” the items in the loop before evaluating whether to loop.

		do {
			$this->current_priority[ $nesting_level ] = current( $this->iterations[ $nesting_level ] );
			$priority                                 = $this->current_priority[ $nesting_level ];

From there, we’ll set a current_priority array to PHP’s internal cursor (current()) of the iterations array. Then set a variable called priority.

If that seems a bit strange to you, don’t worry. Let’s keep going:

			foreach ( $this->callbacks[ $priority ] as $the_ ) {
				if ( ! $this->doing_action ) {
					$args[0] = $value;
				}

Now, we’re going to loop through each callback in the callbacks array for our current priority. I’m not really going to talk about how we decide to call the next callback, except to say that it calls our callback with args. What’s important is that we determine whether we’re doing_action and then if we’re not, change the first argument to the current value. This is the ultimate difference between an action and a filter. With actions, they always receive the original value passed to do_action() while filters are essentially a “reduce” over a value.

		} while ( false !== next( $this->iterations[ $nesting_level ] ) );

		unset( $this->iterations[ $nesting_level ] );
		unset( $this->current_priority[ $nesting_level ] );

		$this->nesting_level--;

		return $value;

Now we clean up after determining whether we continue the loop by advancing the internal pointer for the iterations array.

OK. Cool. Let’s try to understand this nesting business.

Nesting…

So, do you remember this when we were adding items?

		if ( $this->nesting_level > 0 ) {
			$this->resort_active_iterations( $priority, $priority_existed );
		}

Well, this is to handle the case where you add/remove items from the queue that you’re currently iterating. So, let’s try to understand what resort_active_iterations() is doing:

	/**
	 * Handles resetting callback priority keys mid-iteration.
	 *
	 * @since 4.7.0
	 *
	 * @param false|int $new_priority     Optional. The priority of the new filter being added. Default false,
	 *                                    for no priority being added.
	 * @param bool      $priority_existed Optional. Flag for whether the priority already existed before the new
	 *                                    filter was added. Default false.
	 */
	private function resort_active_iterations( $new_priority = false, $priority_existed = false ) {
		$new_priorities = array_keys( $this->callbacks );

		// If there are no remaining hooks, clear out all running iterations.
		if ( ! $new_priorities ) {
			foreach ( $this->iterations as $index => $iteration ) {
				$this->iterations[ $index ] = $new_priorities;
			}

			return;
		}

		$min = min( $new_priorities );

		foreach ( $this->iterations as $index => &$iteration ) {
			$current = current( $iteration );

			// If we're already at the end of this iteration, just leave the array pointer where it is.
			if ( false === $current ) {
				continue;
			}

			$iteration = $new_priorities;

			if ( $current < $min ) {
				array_unshift( $iteration, $current );
				continue;
			}

			while ( current( $iteration ) < $current ) {
				if ( false === next( $iteration ) ) {
					break;
				}
			}

			// If we have a new priority that didn't exist, but ::apply_filters() or ::do_action() thinks it's the current priority...
			if ( $new_priority === $this->current_priority[ $index ] && ! $priority_existed ) {
				/*
				 * ...and the new priority is the same as what $this->iterations thinks is the previous
				 * priority, we need to move back to it.
				 */

				if ( false === current( $iteration ) ) {
					// If we've already moved off the end of the array, go back to the last element.
					$prev = end( $iteration );
				} else {
					// Otherwise, just go back to the previous element.
					$prev = prev( $iteration );
				}

				if ( false === $prev ) {
					// Start of the array. Reset, and go about our day.
					reset( $iteration );
				} elseif ( $new_priority !== $prev ) {
					// Previous wasn't the same. Move forward again.
					next( $iteration );
				}
			}
		}

		unset( $iteration );
	}

Wow. That’s a lot. The first thing is for when we remove all callbacks:

		// If there are no remaining hooks, clear out all running iterations.
		if ( ! $new_priorities ) {
			foreach ( $this->iterations as $index => $iteration ) {
				$this->iterations[ $index ] = $new_priorities;
			}

			return;
		}

Then, we loop through the iterations array, which if you’ll recall is essentially a list of lists of priorities. So, if we had some priorities of 1, 2, 3, then index will be the nesting_level and iteration will be a list: [ 1, 2, 3 ].

After that, we’ll take the internal pointer of iteration, which is the “current priority” that is being processed in WP_Hook::apply_filters(). If we’ve already processed everything, then we just leave it.

		foreach ( $this->iterations as $index => &$iteration ) {
			$current = current( $iteration );

			// If we're already at the end of this iteration, just leave the array pointer where it is.
			if ( false === $current ) {
				continue;
			}

After that, we simply replace iteration with new_priorities. It’s important to note that iteration is a reference and not a copy. In PHP land, that means it was a literal replacement. What happened to the internal pointer? Why, it gets reset back to the beginning of the array. For example, the following will output 'a','a':

$thing = [ 0, 1, 2 ];
$what = &$thing;
$new = [ 'a', 'b', 'c' ];
$what = $new;
echo implode( ',', [ current( $what ), current( $new ) ] );

What this means, is that once we return back to the loop in WP_Hook::apply_filters(), we’ll start over. But, say we were currently processing priority 10, we delete all priority 10s and then add a priority 20? The next bit of code ensures that when we return, we’ll still process the later priorities:

			$iteration = $new_priorities;

			if ( $current < $min ) {
				array_unshift( $iteration, $current );
				continue;
			}

Then, we’ll “fast forward” to the current iteration so we don’t double run things:

			while ( current( $iteration ) < $current ) {
				if ( false === next( $iteration ) ) {
					break;
				}
			}

Then we’ll hit a bit of newer code that includes some helpful comments:

			// If we have a new priority that didn't exist, but ::apply_filters() or ::do_action() thinks it's the current priority...
			if ( $new_priority === $this->current_priority[ $index ] && ! $priority_existed ) {
				/*
				 * ...and the new priority is the same as what $this->iterations thinks is the previous
				 * priority, we need to move back to it.
				 */

				if ( false === current( $iteration ) ) {
					// If we've already moved off the end of the array, go back to the last element.
					$prev = end( $iteration );
				} else {
					// Otherwise, just go back to the previous element.
					$prev = prev( $iteration );
				}

				if ( false === $prev ) {
					// Start of the array. Reset, and go about our day.
					reset( $iteration );
				} elseif ( $new_priority !== $prev ) {
					// Previous wasn't the same. Move forward again.
					next( $iteration );
				}
			}

And bam, we’ve added things to the queue without affecting our current iteration. This is some clever usage of the internal cursor and makes it really hard to reason about, though highly optimized for its use-case.

In fact, it’s the use of the internal cursor that seems to make this code hard to reason about, keeping track of when it moves, resets, or is read from is quite confusing. Though, this is also not easy to replicate in another language; it’s extremely efficient at what it does.

Behavior

Let’s try an understand the behavior of these functions given some sample code:

  1. Adding a list of items with various priorities
  2. Adding a list of items with the same priorities
  3. Adding/removing a new item with a lower priority than we’re currently processing
  4. Adding/removing a new item with a higher priority than we’re currently processing

To do that, we’re going to have a wee bit of helper code:

<?php

require_once __DIR__ . '/src/wp-includes/class-wp-hook.php';

/**
 * Builds Unique ID for storage and retrieval.
 *
 * The old way to serialize the callback caused issues and this function is the
 * solution. It works by checking for objects and creating a new property in
 * the class to keep track of the object and new objects of the same class that
 * need to be added.
 *
 * It also allows for the removal of actions and filters for objects after they
 * change class properties. It is possible to include the property $wp_filter_id
 * in your class and set it to "null" or a number to bypass the workaround.
 * However this will prevent you from adding new classes and any new classes
 * will overwrite the previous hook by the same class.
 *
 * Functions and static method callbacks are just returned as strings and
 * shouldn't have any speed penalty.
 *
 * @link https://core.trac.wordpress.org/ticket/3875
 *
 * @since 2.2.3
 * @since 5.3.0 Removed workarounds for spl_object_hash().
 *              `$hook_name` and `$priority` are no longer used,
 *              and the function always returns a string.
 * @access private
 *
 * @param string   $hook_name Unused. The name of the filter to build ID for.
 * @param callable $callback  The function to generate ID for.
 * @param int      $priority  Unused. The order in which the functions
 *                            associated with a particular action are executed.
 * @return string Unique function ID for usage as array key.
 */
function _wp_filter_build_unique_id( $hook_name, $callback, $priority ) {
	if ( is_string( $callback ) ) {
		return $callback;
	}

	if ( is_object( $callback ) ) {
		// Closures are currently implemented as objects.
		$callback = array( $callback, '' );
	} else {
		$callback = (array) $callback;
	}

	if ( is_object( $callback[0] ) ) {
		// Object class calling.
		return spl_object_hash( $callback[0] ) . $callback[1];
	} elseif ( is_string( $callback[0] ) ) {
		// Static calling.
		return $callback[0] . '::' . $callback[1];
	}
}

function get_output_function(int $serial_number, callable $predicate): callable {
	return function($input) use ($serial_number, $predicate) {
		if (DO_OUTPUT) echo "Processing $serial_number\n";
		return $predicate($input);
	};
}

function get_serial_funcs(int $num_of_funcs, callable $predicate, int $starting_serial = 0): Generator {
	for($x = $starting_serial; $x < $num_of_funcs + $starting_serial; $x++) {
		yield $x => get_output_function($x, $predicate);
	}
}

function inject_serial_func(Generator $serial_funcs, callable $predicate, ?int $location = null): Generator {
	$counter = 0;
	$key = -1;
	foreach($serial_funcs as $key => $func) {
		if($counter++ === $location) {
			echo "Injected $predicate into $key\n";
			yield $key => $predicate;
		} else {
			yield $key => $func;
		}
	}
	if($location === null) {
		yield $key + 1 => $predicate;
	}
}

$add_one = fn(int $input) => $input + 1;

$hook = new WP_Hook();

List with priorities

I used the following configuration:

const NUM_FUNCS = 10000;
const NUM_FUNCS_PER_PRIORITY = 10;
const DO_OUTPUT = true;

$funcs = get_serial_funcs(NUM_FUNCS, $add_one);
foreach($funcs as $serial => $func) {
	$priority = (NUM_FUNCS - $serial) % NUM_FUNCS_PER_PRIORITY;
	echo "Inserting serial $serial in $priority\n";
	$hook->add_filter('test', $func, $priority, 1);
}

$start_time = microtime(true);
$hook->do_action([0]);
$end_time = microtime(true);

echo "Processed all hooks in " . ($end_time - $start_time) . "s\n";

With output off, it can process 10,000 predicates in ~3ms on my machine. Not bad… This is what the output looks like for 100 predicates:

Inserting serial 0 in 0
Inserting serial 1 in 9
Inserting serial 2 in 8
Inserting serial 3 in 7
Inserting serial 4 in 6
Inserting serial 5 in 5
Inserting serial 6 in 4
Inserting serial 7 in 3
Inserting serial 8 in 2
Inserting serial 9 in 1
Inserting serial 10 in 0
Inserting serial 11 in 9
Inserting serial 12 in 8
Inserting serial 13 in 7
Inserting serial 14 in 6
Inserting serial 15 in 5
Inserting serial 16 in 4
Inserting serial 17 in 3
Inserting serial 18 in 2
Inserting serial 19 in 1
Inserting serial 20 in 0
Inserting serial 21 in 9
Inserting serial 22 in 8
Inserting serial 23 in 7
Inserting serial 24 in 6
Inserting serial 25 in 5
Inserting serial 26 in 4
Inserting serial 27 in 3
...
Processing 0
Processing 10
Processing 20
Processing 30
Processing 40
Processing 50
Processing 60
Processing 70
Processing 80
Processing 90
Processing 9
Processing 19
Processing 29
Processing 39
Processing 49
Processing 59
Processing 69
Processing 79
Processing 89
Processing 99
Processing 8
Processing 18
Processing 28
Processing 38
Processing 48
Processing 58
Processing 68
Processing 78
Processing 88
Processing 98
Processing 7
Processing 17
Processing 27
Processing 37
Processing 47
Processing 57
Processing 67
Processing 77
Processing 87
Processing 97
Processing 6
Processing 16
Processing 26
Processing 36
Processing 46
Processing 56
Processing 66
Processing 76
Processing 86
Processing 96
Processing 5
Processing 15
Processing 25
Processing 35
Processing 45
Processing 55
Processing 65
Processing 75
Processing 85
Processing 95
Processing 4
Processing 14
Processing 24
Processing 34
Processing 44
Processing 54
Processing 64
Processing 74
Processing 84
Processing 94
Processing 3
Processing 13
Processing 23
Processing 33

List with same priorities

Using the following configuration:

const NUM_FUNCS = 10000;
const NUM_FUNCS_PER_PRIORITY = 1;
const DO_OUTPUT = true;

$funcs = get_serial_funcs(NUM_FUNCS, $add_one);
foreach($funcs as $serial => $func) {
	$priority = (NUM_FUNCS - $serial) % NUM_FUNCS_PER_PRIORITY;
	echo "Inserting serial $serial in $priority\n";
	$hook->add_filter('test', $func, $priority, 1);
}

$start_time = microtime(true);
$hook->do_action([0]);
$end_time = microtime(true);

echo "Processed all hooks in " . ($end_time - $start_time) . "s\n";

The time to process with output turned off is exactly the same. The output is similiar:

Inserting serial 0 in 0
Inserting serial 1 in 0
Inserting serial 2 in 0
Inserting serial 3 in 0
Inserting serial 4 in 0
Inserting serial 5 in 0
Inserting serial 6 in 0
Inserting serial 7 in 0
Inserting serial 8 in 0
Inserting serial 9 in 0
Inserting serial 10 in 0
Inserting serial 11 in 0
Inserting serial 12 in 0
Inserting serial 13 in 0
Inserting serial 14 in 0
Inserting serial 15 in 0
Inserting serial 16 in 0
Inserting serial 17 in 0
Inserting serial 18 in 0
Inserting serial 19 in 0
Inserting serial 20 in 0
Inserting serial 21 in 0
Inserting serial 22 in 0
Inserting serial 23 in 0
Inserting serial 24 in 0
Inserting serial 25 in 0
Inserting serial 26 in 0
Inserting serial 27 in 0
...
Processing 1190
Processing 1191
Processing 1192
Processing 1193
Processing 1194
Processing 1195
Processing 1196
Processing 1197
Processing 1198
Processing 1199
Processing 1200
Processing 1201
Processing 1202
Processing 1203
Processing 1204
Processing 1205
Processing 1206
Processing 1207
Processing 1208
Processing 1209
Processing 1210
Processing 1211
Processing 1212
Processing 1213
Processing 1214
Processing 1215
Processing 1216
Processing 1217
Processing 1218
Processing 1219
Processing 1220
Processing 1221
Processing 1222
Processing 1223
Processing 1224
Processing 1225
Processing 1226
Processing 1227
Processing 1228
Processing 1229
Processing 1230
Processing 1231
Processing 1232
Processing 1233

Removing and adding lower priority items

This is where it will get interesting, maybe. Let’s find out. Here’s the configuration:

const NUM_FUNCS = 20;
const NUM_FUNCS_PER_PRIORITY = 10;
const DO_OUTPUT = true;

function remove_me(int $a): int {
	echo "\nI should have been removed!\n\n";
	return $a;
}

function remove_func(int $a): int {
	global $hook;
	$hook->remove_filter('test', 'remove_me', 0);
	echo "removed func!\n";
	return $a;
}

$funcs = get_serial_funcs(NUM_FUNCS, $add_one);
$funcs = inject_serial_func($funcs, 'remove_me', 10); // priority 0
$funcs = inject_serial_func($funcs, 'remove_func', 18); // priority 2
foreach($funcs as $serial => $func) {
	$priority = (NUM_FUNCS - $serial) % NUM_FUNCS_PER_PRIORITY;
	echo "Inserting serial $serial in $priority\n";
	$hook->add_filter('test', $func, $priority, 1);
}

$start_time = microtime(true);
$hook->do_action([0]);
$end_time = microtime(true);

echo "Processed all hooks in " . ($end_time - $start_time) . "s\n";

And here’s the output:

Inserting serial 0 in 0
Inserting serial 1 in 9
Inserting serial 2 in 8
Inserting serial 3 in 7
Inserting serial 4 in 6
Inserting serial 5 in 5
Inserting serial 6 in 4
Inserting serial 7 in 3
Inserting serial 8 in 2
Inserting serial 9 in 1
Injected remove_me into 10
Inserting serial 10 in 0
Inserting serial 11 in 9
Inserting serial 12 in 8
Inserting serial 13 in 7
Inserting serial 14 in 6
Inserting serial 15 in 5
Inserting serial 16 in 4
Inserting serial 17 in 3
Injected remove_func into 18
Inserting serial 18 in 2
Inserting serial 19 in 1
Processing 0

I should have been removed!

Processing 9
Processing 19
Processing 8
removed func!
Processing 7
Processing 17
Processing 6
Processing 16
Processing 5
Processing 15
Processing 4
Processing 14
Processing 3
Processing 13
Processing 2
Processing 12
Processing 1
Processing 11

As should have been expected, we can’t change the past! So let’s add something to a lower priority!

Here’s the configuration:

const NUM_FUNCS = 20;
const NUM_FUNCS_PER_PRIORITY = 10;
const DO_OUTPUT = true;

function remove_me(int $a): int {
	echo "\nI should have been removed!\n\n";
	return $a;
}

function i_am_new(int $a): int {
	echo "\nI am new!\n\n";
	return $a;
}

function remove_func(int $a): int {
	global $hook;
	$hook->remove_filter('test', 'remove_me', 0);
	echo "removed func!\n";
	return $a;
}

function add_func(int $a): int {
	global $hook;
	$hook->add_filter('test', 'i_am_new', 0, 1);
	echo "added func!\n";
	return $a;
}

$funcs = get_serial_funcs(NUM_FUNCS, $add_one);
$funcs = inject_serial_func($funcs, 'add_func', 18); // priority 2
foreach($funcs as $serial => $func) {
	$priority = (NUM_FUNCS - $serial) % NUM_FUNCS_PER_PRIORITY;
	echo "Inserting serial $serial in $priority\n";
	$hook->add_filter('test', $func, $priority, 1);
}

$start_time = microtime(true);
$hook->do_action([0]);
$end_time = microtime(true);

echo "Processed all hooks in " . ($end_time - $start_time) . "s\n";

And the output:

Inserting serial 0 in 0
Inserting serial 1 in 9
Inserting serial 2 in 8
Inserting serial 3 in 7
Inserting serial 4 in 6
Inserting serial 5 in 5
Inserting serial 6 in 4
Inserting serial 7 in 3
Inserting serial 8 in 2
Inserting serial 9 in 1
Inserting serial 10 in 0
Inserting serial 11 in 9
Inserting serial 12 in 8
Inserting serial 13 in 7
Inserting serial 14 in 6
Inserting serial 15 in 5
Inserting serial 16 in 4
Inserting serial 17 in 3
Injected add_func into 18
Inserting serial 18 in 2
Inserting serial 19 in 1
Processing 0
Processing 10
Processing 9
Processing 19
Processing 8
added func!
Processing 7
Processing 17
Processing 6
Processing 16
Processing 5
Processing 15
Processing 4
Processing 14
Processing 3
Processing 13
Processing 2
Processing 12
Processing 1
Processing 11
Processed all hooks in 0.0010130405426025s

Again, we learn we cannot change the past!

Removing and adding higher priority items

I lied, I’m not going to bore you with the exact same output. You can’t change the future either..

So, what’s up with all that complexity?

Let me show you this monster:

function add_func(int $a): int {
	global $hook;
	$hook->add_filter('test', 'i_am_new', 0, 1);
	$hook->remove_filter('test', 'add_func', 2);
	echo "added func!\nRecursively calling self\n";
	$hook->do_action([100]);
	echo "Finished recursive call\n";
	return $a;
}

Now the output is:

Processing 0
Processing 10
Processing 9
Processing 19
Processing 8
added func!
Recursively calling self
Processing 0
Processing 10

I am new!

Processing 9
Processing 19
Processing 8
Processing 7
Processing 17
Processing 6
Processing 16
Processing 5
Processing 15
Processing 4
Processing 14
Processing 3
Processing 13
Processing 2
Processing 12
Processing 1
Processing 11
Finished recursive call
Processing 7
Processing 17
Processing 6
Processing 16
Processing 5
Processing 15
Processing 4
Processing 14
Processing 3
Processing 13
Processing 2
Processing 12
Processing 1
Processing 11

Pretty neat? It reran all the previous predicates the second time. However, there’s an exception here:

function add_func(int $a): int {
	global $hook;
	$hook->remove_all_filters();
	$hook->add_filter('test', 'i_am_new', 0, 1);
	echo "added func!\nRecursively calling self\n";
	$hook->do_action([100]);
	echo "Finished recursive call\n";
	return $a;
}

The output is unexpected after seeing it iterate through the queue twice:

Inserting serial 0 in 0
Inserting serial 1 in 9
Inserting serial 2 in 8
Inserting serial 3 in 7
Inserting serial 4 in 6
Inserting serial 5 in 5
Inserting serial 6 in 4
Inserting serial 7 in 3
Inserting serial 8 in 2
Inserting serial 9 in 1
Inserting serial 10 in 0
Inserting serial 11 in 9
Inserting serial 12 in 8
Inserting serial 13 in 7
Inserting serial 14 in 6
Inserting serial 15 in 5
Inserting serial 16 in 4
Inserting serial 17 in 3
Injected add_func into 18
Inserting serial 18 in 2
Inserting serial 19 in 1
Processing 0
Processing 10
Processing 9
Processing 19
Processing 8
added func!
Recursively calling self

I am new!

Finished recursive call

Pretty. interesting.

Dissection

It seems that hooks have some very interesting behaviors and I can’t wait to see how WordPress takes advantage of it. Let’s move up the stack to kses, after which, we’ll be able to explore how WordPress boots.