Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 02 / 06 30 MIN

The plugin model

The Request Lifecycle Your Code Runs Inside

The hooks a front-end request fires in order, what already exists at each one, and why init is the wrong place for a redirect and the right place for a post type.

A plugin registers a post type on init, flushes rewrite rules on the same hook, and the site returns 404s for every entry of that type. Or the plugin hooks wp_loaded, calls is_page( 'contact' ) and redirects, and the redirect fires on the contact page and on every other page too. Or the plugin enqueues a stylesheet from inside a shortcode, and half the time the styles arrive at the bottom of the document.

None of those are mistakes about API syntax. They are mistakes about time. A WordPress request is a fixed sequence of loading stages, and each hook is a window into it. Code that asks a question before the answer exists gets a false, with no warning. Once you know what is already true at each stage, hook choice stops being folklore.

A front-end request is a fixed boot sequence

The entry point is index.php in the WordPress root. It defines WP_USE_THEMES and includes wp-blog-header.php. That file includes wp-load.php, which finds and loads wp-config.php and then wp-settings.php. Everything before that point is bootstrap: constants, the database connection, the error handler, the files that define core functions. None of it is hookable. By the time plugins load, there is a working WordPress to attach to.

From there the sequence is short and worth memorising:

  1. Must-use plugins load, one file at a time, then muplugins_loaded fires.
  2. Taxonomies and post types that core owns are registered, and the admin plugin API is loaded so that header parsing is available.
  3. Active plugins load in stored order. Each one is included, then plugin_loaded fires for that file.
  4. plugins_loaded fires. Pluggable functions are available from this point.
  5. The request object, the global query object, the rewrite object, the widget factory and the roles object are created.
  6. setup_theme fires, then the theme's functions.php is included, then after_setup_theme fires.
  7. The current user is loaded, then init fires.
  8. wp_loaded fires. Loading is finished.
  9. wp-blog-header.php calls wp(), which parses the request into query variables and runs the main query. parse_request and send_headers fire around this, and the wp action fires at the end of it.
  10. template-loader.php fires template_redirect, decides which template file answers the request, and includes it. The template then calls wp_head() and wp_footer().

Two hooks in the middle are easy to confuse because they are nearly adjacent. init fires inside wp-settings.php. wp_loaded fires at the end of the same file, a few lines and several function calls later. Both run on every request, front end, admin, REST and cron alike.

You can read the order off a running site

Do not take the list above on trust. A plugin file with one block of code will print the order for your install, with your plugin set, in a few seconds. Register the same logger on each hook and stamp the time.

<?php
/**
 * Plugin Name: Request Order Probe
 * Version: 0.1.0
 */

defined( 'ABSPATH' ) || exit;

add_action( 'plugins_loaded', 'wpsifero_probe', 0 );
add_action( 'setup_theme', 'wpsifero_probe', 0 );
add_action( 'after_setup_theme', 'wpsifero_probe', 0 );
add_action( 'init', 'wpsifero_probe', 0 );
add_action( 'wp_loaded', 'wpsifero_probe', 0 );
add_action( 'template_redirect', 'wpsifero_probe', 0 );

function wpsifero_probe(): void {
    static $start = null;
    $start ??= microtime( true );

    $hook = current_action();

    error_log( sprintf(
        '%s +%0.1fms did_wp=%d user=%d',
        $hook,
        ( microtime( true ) - $start ) * 1000,
        did_action( 'wp' ),
        get_current_user_id()
    ) );
}

Loaded as a must-use plugin, that prints a single ordered list for one page load. The did_wp column is the useful one: it is zero until wp() has run, which tells you exactly which hooks can see the query. Keep the file around; there is no faster way to settle an argument about timing.

init is the default for registration, and it is early enough

Most of what a plugin sets up belongs on init: custom post types and taxonomies, custom rewrite rules and endpoints, roles and capabilities, REST routes registered through rest_api_init, cron schedules, image sizes, and text domain loading for plugins that load their own translations.

All of those have the same shape. They add something to a registry that later code reads. Nothing in the request has read the registry yet when init fires, so registration there is early enough, and it happens late enough that other plugins and the theme are loaded. Registering a post type on plugins_loaded also works and buys you nothing except getting in front of plugins that register on init and might want to modify your labels.

What init is not is a point where the query exists in any usable form. The global query object is created just before setup_theme, so from then on the conditional tags at least do not crash: they call methods on an unpopulated object and answer about nothing. Hook earlier than that and the object is still null, which turns a call such as get_queried_object_id() into a fatal error rather than a quiet false. WordPress runs wp() after wp_loaded, so at init every conditional tag answers about the request rather than about its content. is_singular(), is_page(), is_front_page(), is_search() and have_posts() are all false or empty there, no matter what URL was requested.

add_action( 'wp_loaded', static function () {
    // Never true: the main query has not run yet.
    if ( is_page( 'contact' ) ) {
        wp_safe_redirect( home_url( '/contact-us/' ) );
        exit;
    }
} );

The redirect never happens, and the agent that reports it works on a local site where some other plugin redirects is you, three weeks later. The hook that does see the query is template_redirect, which fires inside template-loader.php after wp() has finished.

add_action( 'template_redirect', static function () {
    if ( is_page( 'contact' ) ) {
        wp_safe_redirect( home_url( '/contact-us/' ), 301 );
        exit;
    }
} );

If you need to change the query rather than react to it, the hook is pre_get_posts, which fires while the query is being built. It is earlier than init in effect but later in the boot sequence, because it runs inside wp() with the query variables already parsed. Anything that should influence which posts come back belongs there; anything that reacts to those posts belongs on template_redirect or later.

wp_loaded is the last chance before the query runs

Between init and the main query there is exactly one hook: wp_loaded. Use it for work that has to be complete before routing decisions are made. Flushing rewrite rules is the standard example, and the reason is mechanical: core postpones a flush until wp_loaded, because rewrite rules are only complete once every plugin has registered its own. Call flush_rewrite_rules() during init and WordPress schedules the real work for wp_loaded rather than doing it immediately.

The other legitimate use is a redirect that depends on nothing but the URL, since headers have not been sent yet. A version check that sends the visitor to an upgrade page belongs here as much as anywhere, though template_redirect is a friendlier place when the decision depends on the request being a normal page view.

After wp_loaded the request stops being generic. wp() parses the URL into query variables, fires parse_request, runs the main query, fires send_headers, fires wp, and only then does template_redirect open the template stage. When get_header() runs inside the template, wp_enqueue_scripts fires and the enqueue queue is built. Anything enqueued after that has passed its print point, which is why a stylesheet enqueued from a shortcode body arrives late or not at all. Register and enqueue on wp_enqueue_scripts for the front end and admin_enqueue_scripts for admin screens, and make the callback decide what the current request needs.

Admin and REST requests are the same boot with different endings

The first eight steps are identical, which is why a plugin can hook init and behave the same in the admin and on the front end. What differs is the ending and one flag. is_admin() is already true at plugins_loaded in an admin request, because the entry script defines WP_ADMIN before WordPress loads. It answers "is this an admin request", not "am I on a screen where my menu shows".

PathEntryExtra hooksWhen the current user is ready
Front end index.php to wp-blog-header.php parse_request, wp, template_redirect, wp_head Before init
Admin screen wp-admin/admin.php admin_init, then load-{page} for the screen Before init, same as front end
Admin POST or AJAX wp-admin/admin-post.php, admin-ajax.php admin_init, then the admin_post_{action} hook Before init
REST request /wp-json/ rewritten to the REST query variable rest_api_init, then the route callback Before init, or by the request's own authentication

An admin request reaches wp_loaded during loading, then admin.php includes the admin API and fires admin_init. That is the hook for capabilities-dependent setup: registering settings, adding meta boxes, checking whether the current user may do what they are trying to do. It is also where a plugin reads the $pagenow global and the screen id, both of which exist by then.

A REST request is a front-end request that ends differently. Core hooks rest_api_init onto init, and when the request carries the REST query variable, rest_api_loaded takes over during parse_request and dispatches the route. Two consequences follow. Route registration belongs on rest_api_init, because registering earlier means the server is not set up yet and the route is refused. And a permission callback runs on the REST path only, so it cannot be the only authorisation check for an operation that is also reachable from an admin form or an AJAX handler.

The one hook whose placement changed recently

In WordPress 6.9, core moved the routine that spawns a loopback request to wp-cron.php from wp_loaded to shutdown, so the HTML can be flushed while the cron request is set up. The old placement still applies when ALTERNATE_WP_CRON is defined. Do not assume cron dispatch is finished by the time wp_loaded runs.

The limitation of everything above is that it describes one request. WordPress keeps no process between requests, so anything you need later goes in the database or the object cache. A plugin written as though an in-memory registry survived from one page view to the next will misbehave in ways that no amount of hook ordering fixes.

Lab 30 minutes

Install the probe from this lesson as wp-content/mu-plugins/00-request-order.php on a local site. Load a post, the blog index, an admin screen and a REST route (/wp-json/wp/v2/posts), and keep the four ordered lists.

Then add a second logger to the same file, recording three booleans per hook: is_admin(), did_action( 'wp' ) and in_the_loop(). Add rest_api_init and admin_init to the list of hooks, and run the same four requests again.

Answer four questions from the output. Which hook is the first to see the main query finished? Where does rest_api_init land relative to init and wp_loaded? Does admin_init appear in the REST list at all? And on the admin screen, does template_redirect appear?

Show the solution and the reasoning

The front-end order is plugins_loaded, setup_theme, after_setup_theme, init, wp_loaded, template_redirect. Milliseconds between them are small on a local site and irrelevant; the order is the point.

Only template_redirect sees the main query finished. wp_loaded is the last hook before wp() runs, so did_action( 'wp' ) is zero there, is_front_page() is false on the front page, and the conditional tags answer about nothing. That is the entire explanation for redirects and conditionals that never fire.

plugins_loaded        is_admin=0 did_wp=0 in_loop=0
setup_theme           is_admin=0 did_wp=0 in_loop=0
after_setup_theme     is_admin=0 did_wp=0 in_loop=0
init                  is_admin=0 did_wp=0 in_loop=0
wp_loaded             is_admin=0 did_wp=0 in_loop=0
template_redirect     is_admin=0 did_wp=1 in_loop=0

rest_api_init         is_admin=0 did_wp=0 in_loop=0

admin_init            is_admin=1 did_wp=0 in_loop=0

in_the_loop() stays false at template_redirect because the loop has not started. It becomes true once the template calls the_post(), which is the signal that you are inside a post's rendering rather than looking at a resolved query.

rest_api_init lands after wp_loaded, because core hooks it to init and it is the server setup routine that runs when the request turns out to be a REST one. Register routes there, and expect your route callback to run after it. Dispatch happens during parse_request, which is why a REST request shows both.

admin_init never appears in the REST list, and rest_api_init never appears in the admin list. Each is fired by its own entry path. A plugin that registers settings on admin_init and REST routes on rest_api_init is correct; one that registers REST routes on admin_init works when a human clicks a link and fails for every machine client.

On the admin screen there is no template_redirect. The admin never reaches template-loader.php, so a redirect written for the front end silently does nothing there. Handle both paths, or accept the duplication across admin_init and template_redirect.

The choice that follows from all of this is mechanical once you have the list. Registration that later code reads goes on init. Anything that must be complete before routing goes on wp_loaded. Anything that needs to know what was requested goes on template_redirect or later, or pre_get_posts when it needs to change the answer instead of reading it.

Progress

Mark this lesson done and it stays marked in this browser.