Understanding how WordPress Hooks work from a Stack Trace

I’ve recently completed the “Advanced WordPress Debugging” course on learn.wpvip.com. One of the ideas covered in the course is the use of Stack Traces to understand errors. The chapter about Stack traces made me think on how great they are to understand the inner workings of WordPress. By analyzing stack traces, we can trace the sequence of function calls and understand how the different parts of WordPress code interact with each other.

Provoking an error to show the Stack Trace on a hook execution

Let’s say we want to understand better how callbacks I register in hooks from my plugin are executed in WordPress. For that, we can do a little experiment.

Let’s go to wp-content/plugins/hello.php and add the next line to call a method that is not defined on the init action

add_action( 'init', 'my_non_existing_function' );

If we activate the plugin, next time we reload we get something like this

And if we have a plugin like query-monitor activated we get the same error message in a nicer formatted version

This type of message is called a Stack Trace. This one shows the journey of WordPress when loading a page. The journey starts with index.php and continues until it reaches the point that threw the error.

Analyzing the Stack Trace for a Page Load

Let’s look with attention the stack trace generated by the above error:

Uncaught Error: call_user_func_array(): Argument #1 ($callback) must be a valid callback, function "my_non_existing_function" not found or invalid function name
in /var/www/html/wp-includes/class-wp-hook.php on line 324

Call stack:

WP_Hook::apply_filters(NULL, array)
wp-includes/class-wp-hook.php:348

WP_Hook::do_action(array)
wp-includes/plugin.php:517

do_action('init')
wp-settings.php:704

require_once('/var/www/html/wp-settings.php')
wp-config.php:102

require_once('/var/www/html/wp-config.php')
wp-load.php:50

require_once('/var/www/html/wp-load.php')
wp-blog-header.php:13

require('/var/www/html/wp-blog-header.php')
index.php:17

In this error message we can see important info to trace the code back:

  • We can see the sequence of files from the one that thrown the error to the first file that is executed when a page is loaded in WordPress
  • For each file involved in the sequence we can see the line number where another file is invoked

Breaking Down the Stack Trace

The stack trace above reveals the sequence of operations that occur when WordPress loads a page. Let’s break down each step:

index.php

The Stack trace starts with the index.php which is the first file that is loaded on any WordPress installation when a user accesses the site. It sets up the WordPress environment by including wp-blog-header.php.

require __DIR__ . '/wp-blog-header.php';

wp-blog-header.php

This file is responsible for loading the WordPress environment. It includes wp-load.php, which initializes the necessary settings and files.

require_once __DIR__ . '/wp-load.php';

wp-load.php

This file loads the WordPress configuration file wp-config.php among other things

require_once ABSPATH . 'wp-config.php';

wp-config.php

This file contains configuration settings for the WordPress installation, such as database credentials and other environment-specific settings. It loads wp-settings.php.

require_once ABSPATH . 'wp-settings.php';

wp-settings.php

This file sets up various constants, includes essential files, and initializes the WordPress environment. It triggers the init action, which is a crucial hook in the WordPress lifecycle.

do_action( 'init' );

The Stack Trace is returning an error from the non-existing 'my_non_existing_function' callback being called on the init hook. So, this callback should already be added by this point.
Check the section How’s ‘my_non_existing_function’ registered in the init hook? below to learn more about this.

Up to this point, starting from index.php, WordPress loads and executes code from various files sequentially. It follows a chain of file inclusions until it reaches the do_action('init') hook within wp-settings.php.

wp-includes/plugin.php

The do_action function is defined on wp-includes/plugin.php. This function internally calls the do_action method of the “init” instance of WP_Hook stored in the $wp_filter (global) array

function do_action( $hook_name, ...$arg ) {
	global $wp_filter, $wp_actions, $wp_current_filter;

	if ( ! isset( $wp_actions[ $hook_name ] ) ) {
		$wp_actions[ $hook_name ] = 1;
	} else {
		++$wp_actions[ $hook_name ];
	}

	// Do 'all' actions first.
	if ( isset( $wp_filter['all'] ) ) {
		$wp_current_filter[] = $hook_name;
		$all_args            = func_get_args(); // phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue.NeedsInspection
		_wp_call_all_hook( $all_args );
	}

	if ( ! isset( $wp_filter[ $hook_name ] ) ) {
		if ( isset( $wp_filter['all'] ) ) {
			array_pop( $wp_current_filter );
		}

		return;
	}

	if ( ! isset( $wp_filter['all'] ) ) {
		$wp_current_filter[] = $hook_name;
	}

	if ( empty( $arg ) ) {
		$arg[] = '';
	} elseif ( is_array( $arg[0] ) && 1 === count( $arg[0] ) && isset( $arg[0][0] ) && is_object( $arg[0][0] ) ) {
		// Backward compatibility for PHP4-style passing of `array( &$this )` as action `$arg`.
		$arg[0] = $arg[0][0];
	}

	$wp_filter[ $hook_name ]->do_action( $arg );

	array_pop( $wp_current_filter );
}

wp-includes/class-wp-hook.php

The WP_Hook class manages hooks in WordPress and is defined at wp-includes/class-wp-hook.php .

The class WP_Hook includes the do_action and apply_filters methods

WP_Hook::do_action()

The do_action method is called to execute functions attached to the init action. This method internally calls the apply_filters method from the instance

public function do_action( $args ) {
	$this->doing_action = true;
	$this->apply_filters( '', $args );

	// If there are recursive calls to the current action, we haven't finished it until we get to the last one.
	if ( ! $this->nesting_level ) {
		$this->doing_action = false;
	}
}

WP_Hook::apply_filters()

This method applies filters to the hook, allowing plugins to modify data. In this case, it attempts to call the non-existent function, resulting in an error.

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

	$nesting_level = $this->nesting_level++;

	$this->iterations[ $nesting_level ] = $this->priorities;

	$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, $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;
}

How’s 'my_non_existing_function' registered in the init hook?

When the init hook is triggered, our 'my_non_existing_function' is already registered for that hook. This means the code in our plugin has already been executed. This also applies to all our active plugins.

The code that loads the code of each activated plugin is in wp_settings.php a few lines above the do_action('init'). By the time the do_action('init') is executed, all init hooks from plugins have been registered.

wp-settings.php

It loads the code from all active plugins. The code in this file iterates over each active and valid plugin using wp_get_active_and_valid_plugins(). For each plugin, it includes the plugin file using include_once.

// Load active plugins.
foreach ( wp_get_active_and_valid_plugins() as $plugin ) {
	wp_register_plugin_realpath( $plugin );

	$plugin_data = get_plugin_data( $plugin, false, false );

	$textdomain = $plugin_data['TextDomain'];
	if ( $textdomain ) {
		if ( $plugin_data['DomainPath'] ) {
			$GLOBALS['wp_textdomain_registry']->set_custom_path( $textdomain, dirname( $plugin ) . $plugin_data['DomainPath'] );
		} else {
			$GLOBALS['wp_textdomain_registry']->set_custom_path( $textdomain, dirname( $plugin ) );
		}
	}

	$_wp_plugin_file = $plugin;
	include_once $plugin;
	$plugin = $_wp_plugin_file; // Avoid stomping of the $plugin variable in a plugin.

	/**
	 * Fires once a single activated plugin has loaded.
	 *
	 * @since 5.1.0
	 *
	 * @param string $plugin Full path to the plugin's main file.
	 */
	do_action( 'plugin_loaded', $plugin );
}

wp-content/plugins/hello.php

This is our plugin file where we’ve registered a callback function my_non_existing_function to be executed when the init action is triggered.

add_action( 'init', 'my_non_existing_function' );

wp-includes/plugin.php

The add_action function is defined on the wp-includes/plugin.php file. add_action is used to set up a function to be called at a specific point in the WordPress execution process.

The function add_action calls add_filter, which is responsible for registering the callback function.

function add_action( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) {
	return add_filter( $hook_name, $callback, $priority, $accepted_args );
}

The add_filter function is defined to register callbacks for both actions and filters. It checks if a filter (or action) with the given name already exists in the global $wp_filter array. If it doesn’t exist, it initializes a new WP_Hook instance.

The callback function (my_non_existing_function in this case) is then added to the $wp_filter array under the init action hook.

function add_filter( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) {
	global $wp_filter;

	if ( ! isset( $wp_filter[ $hook_name ] ) ) {
		$wp_filter[ $hook_name ] = new WP_Hook();
	}

	$wp_filter[ $hook_name ]->add_filter( $hook_name, $callback, $priority, $accepted_args );

	return true;
}

Learning from the Stack Trace

From this stack trace, we can infer some ideas about WordPress’s internal processes regarding hooks:

  • There’s a chain of files loaded starting from index.php with code executed that’s needed on ulterior steps
    • index.php loads wp-blog-header.php which loads wp-load.php which loads wp-config.php which loads wp-settings.php
  • Hooks are registered as instances of the WP_Hook class and stored in a global $wp_filter array
  • Hooks are executed when there’s a do_action(<%THE_HOOK%>)
    • Like the do_action('init') in wp-settings.php

Resources

4 responses to “Understanding how WordPress Hooks work from a Stack Trace”

  1. […] Garrido wrote about his Understanding how WordPress Hooks work from a Stack Trace. He shared how to use stack traces to analyze WordPress hooks, helping developers see how hooks […]

  2. […] Garrido wrote about his Understanding how WordPress Hooks work from a Stack Trace. He shared how to use stack traces to analyze WordPress hooks, helping developers see how hooks […]

  3. […] Garrido wrote about his Understanding how WordPress Hooks work from a Stack Trace. He shared how to use stack traces to analyze WordPress hooks, helping developers see how hooks […]

  4. Jeromy Adofo Avatar
    Jeromy Adofo

    Thanks for this

Leave a Reply

Navigation

About

Writing on the Wall is a newsletter for freelance writers seeking inspiration, advice, and support on their creative journey.

Discover more from JuanMa Codes

Subscribe now to keep reading and get access to the full archive.

Continue reading