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:
- Must-use plugins load, one file at a time, then
muplugins_loadedfires. - Taxonomies and post types that core owns are registered, and the admin plugin API is loaded so that header parsing is available.
- Active plugins load in stored order. Each one is included, then
plugin_loadedfires for that file. plugins_loadedfires. Pluggable functions are available from this point.- The request object, the global query object, the rewrite object, the widget factory and the roles object are created.
setup_themefires, then the theme'sfunctions.phpis included, thenafter_setup_themefires.- The current user is loaded, then
initfires. wp_loadedfires. Loading is finished.wp-blog-header.phpcallswp(), which parses the request into query variables and runs the main query.parse_requestandsend_headersfire around this, and thewpaction fires at the end of it.template-loader.phpfirestemplate_redirect, decides which template file answers the request, and includes it. The template then callswp_head()andwp_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".
| Path | Entry | Extra hooks | When 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.
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.