Hooks, Line, and Sinker: WordPress’ New WP_Hook Class

#
By Peter Tasker

The hooks system is a central pillar of WordPress and with the 4.7 release a major overhaul of how it works was merged. The Trac ticket that initially raised an issue with the hooks system was logged over 6 years ago. After a few attempts, the updates finally made it into the 4.7 release and the venerable hooks system was overhauled. In this post I want to go over some of the technical changes and decisions that went into the new WP_Hook class. I’ll also go over some of the more interesting aspects of WordPress core development and look into what it takes to overhaul a major feature in WordPress core.

For the purposes of this post I’m going to assume you know what the WordPress hooks system is (i.e. add_filter(), add_action(), apply_filters() and do_action()), and have a general idea of how it works. It would also be a good idea to read over the Make blog post that covers the changes.

What’s Changed?

One of the bigger changes introduced in WordPress 4.7 is that there is a new WP_Hook class. This new class is now used to handle all the internal hooks within WordPress core. It’s kind of a big deal. Pre-4.7, all the hook functions (add_filter() etc.) and logic were handled directly in wp-includes/plugin.php. Now, the WP_Hook class is included from wp-includes/plugin.php and each filter function essentially just calls the corresponding WP_Hook method.

For example, this is what the classic add_filter() function looks like now:

function add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) {
    global $wp_filter;
    if ( ! isset( $wp_filter[ $tag ] ) ) {
        $wp_filter[ $tag ] = new WP_Hook();
    }
    $wp_filter[ $tag ]->add_filter( $tag, $function_to_add, $priority, $accepted_args );
    return true;
}

As you can see the $wp_filter[ $tag ] global variable is now assigned a new instance of WP_Hook which then calls the corresponding WP_Hook::add_filter() method.

Compare this with the older version:

function add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) {
    global $wp_filter, $merged_filters;

    $idx = _wp_filter_build_unique_id($tag, $function_to_add, $priority);
    $wp_filter[$tag][$priority][$idx] = array('function' => $function_to_add, 'accepted_args' => $accepted_args);
    unset( $merged_filters[ $tag ] );
    return true;
}

The key part here is that pre 4.7 versions of this function create a multidimensional array ($wp_filter) to hold all of the queued filters. In the new version, some of that logic has been moved into the WP_Hook class, but as we can see, the $wp_filter global is no longer a deeply nested array, but an array of objects.

To maintain backwards compatibility and avoid breaking people’s code, the WP_Hook class implements the ArrayAccess and Iterator interfaces. Simply, these two interfaces allow the WP_Hook class to be accessible and operate like an array while still having all the functionality of an object.

Performance was a major concern for the new filter functions. The old hook system was extremely quick, with most sites hitting it thousands of times per request. It was important to maintain performance, so why would an updated hooks system use a more abstracted, potentially slower, object based implementation? That answer is in how array pointers work with the do-while loop.

Jonathan Brinly goes over the main issue with the hooks system (and consequently came up with a large part of the solution). What it boils down to is that the old add_action() implementation used a foreach loop inside a do-while loop, with a call to next($wp_filter[$tag]) in the while clause:

do {
    foreach ( (array) current($wp_filter[$tag]) as $the_ )
        if ( !is_null($the_['function']) ){
            $args[1] = $value;
            $value = call_user_func_array($the_['function'], array_slice($args, 1, (int) $the_['accepted_args']));
        }

} while ( next($wp_filter[$tag]) !== false );

The first sentence in the Trac ticket outlines the main issue with this logic:

When calling a specific hook from a function that was called through that same hook, the remaining hooked functions from the first iteration will be discarded.

The issue occurs because of recursion, specifically when a callback hooks onto the same action. Jonathan has a good example of this in his post:

function my_first_callback( $post_id, $post ) {
    if ( $post->post_type == 'post' ) {
        wp_insert_post( array(
        'post_title' => 'A Post',
        'post_status' => 'publish',
        'post_type' => 'a_custom_post_type',
        ) );
    }
}
function my_second_callback( $post_id, $post ) {
// do something else
}
add_action('save_post', 'my_first_callback', 10, 2);
//Never called
add_action('save_post', 'my_second_callback', 15, 2);

Notice the placement of the wp_insert_post() function? That function call will trigger another save_post action, creating a nested, recursive loop of callbacks.

This nested wp_insert_post() call triggers do_action('save_post') and causes the do_action function to loop through the $wp_filter['save_post'] array.

This happens until a call to the PHP next() function in the while clause is triggered. Because of the nesting of the do_action calls, at this point the array pointer is moved to the end of the $wp_filter['save_post'] array, and the loop is ended.

In this scenario, the add_action( 'save_post', 'my_second_callback', 15, 2 ) block never gets called. If you remember how $wp_filter is structured, you’ll see why this happens:

$wp_filter[ $tag ][ $priority ][ $idx ] = array( 'function' => $function_to_add, 'accepted_args' => $accepted_args );

In this case, the inner $wp_filter[ ‘save_post’ ] is triggered until next() is called moving the array pointer to the end of the inner array. After that loop is finished, any future actions hooked on at the top-level level of the $wp_filter array with a later priority are not executed. This is because WordPress thinks the $wp_filter array has been completely looped over.

do {
    foreach ( (array) current( $wp_filter[ $tag ] ) as $the_ ) {
        if ( ! is_null( $the_['function'] ) ) {
            $args[1] = $value;
            $value   = call_user_func_array( $the_['function'], array_slice( $args, 1, (int) $the_['accepted_args'] ) );
        }
    }

} while ( next( $wp_filter[ $tag ] ) !== false ); // <-- moves the array pointer to the end of the array once $wp_filter[$tag] has been looped over

You can see above that the the old code in apply_filters() didn’t have any knowledge of the nesting level of the callback.

This issue is arguably a bit of an edge case, but it highlights the brittleness of the core hook system’s reliance on a multi-dimensional array.

The Fix

The main fix was to keep track of the current iteration when the hook methods are fired. The meat of the new WP_Hook class is keeping track of the current iteration in order to get around this recursion issue. Further, by switching the $wp_filter global variable to an array of objects, the add_filter() and related functions now store the hooked functions in the $callbacks class property.

The new add_filter() method in WP_Hook:

public function add_filter( $tag, $function_to_add, $priority, $accepted_args ) {
    $idx = _wp_filter_build_unique_id( $tag, $function_to_add, $priority );
    $priority_existed = isset( $this->callbacks[ $priority ] );

    $this->callbacks[ $priority ][ $idx ] = array(
        'function' => $function_to_add,
        '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 old add_filter()function:

function add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) {
    global $wp_filter, $merged_filters;

    $idx = _wp_filter_build_unique_id($tag, $function_to_add, $priority);
    $wp_filter[$tag][$priority][$idx] = array('function' => $function_to_add, 'accepted_args' => $accepted_args);
    unset( $merged_filters[ $tag ] );
    return true;
}

Notice that new call to $this->resort_active_iterations( $priority, $priority_existed ) in the new code? That’s a new method that was created to handle resetting callback priorities during an iteration. This method is part of what fixes the iteration priority issue noted earlier. All this looping making your head spin?

Yo Dawg

On execution of each filter you can see the differences as well. The new apply_filters() code in WP_Hook:

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 ] = $priority = current( $this->iterations[ $nesting_level ] );

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

            // Avoid the array_slice if possible.
            if ( $the_['accepted_args'] == 0 ) {
                $value = call_user_func_array( $the_['function'], array() );
            } 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;
}

The old apply_filters() code in wp-includes/plugin.php:

function apply_filters( $tag, $value ) {
    global $wp_filter, $merged_filters, $wp_current_filter;
...

    do {
        foreach ( (array) current($wp_filter[$tag]) as $the_ )
            if ( !is_null($the_['function']) ){
                $args[1] = $value;
                $value = call_user_func_array($the_['function'], array_slice($args, 1, (int) $the_['accepted_args']));
            }

    } while ( next($wp_filter[$tag]) !== false );

    array_pop( $wp_current_filter );

    return $value;
}

As you can see, the new version of the code handles the current iteration of the filter as well as the current state of the filter when applying the callback. The previous version of apply_filters() didn’t account for the nesting level, iteration, or priority of the currently executing hook.

Backwards Compatibility

As you may know, one of the key concepts in WordPress core development is backwards compatibility. WordPress does a great job of making sure older code in plugins and themes still run with the latest version. Oftentimes this means more recent features of PHP cannot be used, and it requires some creative thinking to keep things working with legacy code.

In the case of WP_Hook, this meant using the ArrayAccess and Iterator interfaces to keep ‘array like’ functionality available for older code that touched $wp_filter directly.

Looking through the history of the patch, much of the initial code for the WP_Hook class went through considerable refactoring to support PHP 5.2.

Whether you agree with it or not, it is clear the WordPress core developers keep backwards compatibility front of mind and make sure all new code (especially code that affects every developer) works, regardless of environment.

What Does This Mean for Developers?

As the make.wordpress.org post outlines, for many people this new class shouldn’t have any effect on their code. That is, if you’re using add_action() or add_filter() in your code, you’re all set. The main issue arises for code that uses $wp_filter directly. There are a few instances where your code may be effected, and they’re outlined in the post. We made a small modification in the WP Migrate DB Pro plugin as we were modifying the $wp_filter global in the compatibility mu-plugin.

Another key thing to highlight here is how the WordPress core team plans major changes to the codebase. It shows how important backwards compatibility is for the project and demonstrates some creative approaches for supporting legacy code.

The development of the WP_Hookclass is an interesting case for developers to take note of as it highlights how large code updates are added to WordPress core. Some of the approaches used may be helpful for refactoring your own code.

It’s exciting times, especially now, with the core development process being revamped and the REST API taking over wp-admin in WordPress 4.8!

So there you have it, a brief overview of what’s changed with the hooks system in WordPress core. There were many other updates that shipped with 4.7 (including the REST API content endpoints) and I encourage you to check out the field guide to see what else was updated.

What do you think about the new WP_Hook class? Have you participated in the WordPress release process yourself? What are your thoughts on backwards compatibility? Let us know in the comments.

About the Author

Peter Tasker

Peter is a PHP and JavaScript developer from Ottawa, Ontario, Canada. In a previous life he worked for marketing and public relations agencies. Love's WordPress, dislikes FTP.