Stop asking when a hook fires. Ask what is loaded when it does.

Thirteen core hooks in fire-order. Each one is not a moment on a clock. It is a snapshot of the state WordPress has finished loading.

  1. Boot muplugins_loaded wp-settings.php
  2. Load plugins_loaded wp-settings.php
  3. Load setup_theme wp-settings.php
  4. Load after_setup_theme wp-settings.php
  5. Load init wp-settings.php:780
  6. Load wp_loaded wp-settings.php
  7. Query parse_request class-wp.php
  8. Query wp class-wp.php:838
  9. Render template_redirect template-loader.php:23
  10. Render wp_enqueue_scripts wp_head path
  11. Render wp_head general-template.php
  12. Render the_content post-template.php
  13. Teardown shutdown load.php

The WordPress request lifecycle and hook system, source-traced from index.php to shutdown

A hook is not a moment on a clock. It is a snapshot of the state WordPress has finished loading. This guide traces every stage from index.php to shutdown, annotates each core do_action() call site with the state it makes available, and shows the concrete broken output you get from hooking one stage too early or too late.

Stop asking when a hook fires. Ask what is loaded when it does.#

Most guides to the WordPress request lifecycle hand you a list of hooks in order. That is necessary, but it is not the answer you came for. The order tells you when. It does not tell you what state exists at that point. Therefore it cannot tell you where to hook a given task.

Consider the guides that usually rank for this topic. The official documentation gives the fire-order without the state. The internals deep-dives give the mechanics without the placement guidance. The performance-focused walkthroughs give the lifecycle in outline without the state at each stage. Each holds one piece. None fuses the three.

This page fuses them. For every stage it names the exact core do_action() call site, the runtime state that becomes available there, what is still missing, and the concrete broken output from hooking too early or too late. In short, it treats the WordPress request lifecycle as a placement decision, not a glossary. That reframe is the whole differentiation, so the rest of the page earns it stage by stage.

The boot path, source-traced from index.php to rendered page#

Before the hooks make sense, you need the file-to-file call chain. Otherwise the call-site annotations later have nothing to anchor to. So start with the causal spine. The diagram below traces one front-end request from the entry file down to teardown, with the key do_action() sites marked on the exact stage they fire.

The WordPress boot path from index.php to shutdown, with each core do_action() call site marked on its stageindex.php requires wp-blog-header.php, which loads wp-load.php, then wp-config.php, then wp-settings.php. wp-settings.php fires muplugins_loaded, plugins_loaded, the theme setup hooks, and init in a fixed order. Then wp() runs WP::main(), which parses the request and builds the main query before firing the wp action. template_redirect opens the render phase, and shutdown closes the request.

index.php to wp-blog-header.php to wp-load.php to wp-config.php#

The public entry point is tiny. index.php defines one constant and requires wp-blog-header.php. Nothing else happens in the entry file, which surprises people expecting a large front controller.

index.php · php
// index.php: the whole public site is three lines. It defines a constant, then
// hands off to wp-blog-header.php. Nothing else runs here.
define( 'WP_USE_THEMES', true );

// Load the WordPress bootstrap, then render the current request.
require __DIR__ . '/wp-blog-header.php';

Next, wp-blog-header.php lays out the three-step spine of every front-end request. First it loads core through wp-load.php, which finds and requires wp-config.php, which in turn requires wp-settings.php. Then it calls wp() to resolve the query. Finally it hands off to template-loader.php to render.

wp-blog-header.php · php
// wp-blog-header.php: the three-step spine of every front-end request. Load core,
// resolve the query, then render the template.
if ( ! isset( $wp_did_header ) ) {
    $wp_did_header = true;

    require_once __DIR__ . '/wp-load.php';   // -> wp-config.php -> wp-settings.php
    wp();                                    // WP::main(): parse_request + main query
    require_once ABSPATH . WPINC . '/template-loader.php';
}

wp-settings.php: MU-plugins, then plugins, then theme functions.php#

wp-settings.php is where the ordering story lives. Because it fires the load hooks in a fixed sequence, this file is the reason muplugins_loaded precedes plugins_loaded precedes after_setup_theme precedes init. You do not get to reorder these. Instead, you pick the stage that matches what must already be loaded.

wp-settings.php · php
// wp-settings.php: the ordering story in one place. Each do_action() is a stage,
// and the ORDER is load-bearing. MU-plugins first, then plugins, then the theme,
// then init. You cannot reorder these, so you hook the stage that fits your need.
do_action( 'muplugins_loaded' );   // only must-use plugins are loaded
// ... regular plugins are required here ...
do_action( 'plugins_loaded' );     // every active plugin is now in memory
// ... the theme's functions.php is required here ...
do_action( 'setup_theme' );
do_action( 'after_setup_theme' );  // theme supports declared
do_action( 'init' );               // wp-settings.php line 780: register CPTs here
do_action( 'wp_loaded' );          // everything is loaded, nothing is queried yet

Notice what init means in practice. By init, every plugin and the theme are in memory, so init is the canonical slot to register a custom post type. However, the request has not been parsed into a query yet. Therefore is_single() and the queried object are still empty at init, which is the single most common mis-hook in WordPress.

wp() to WP::main() to parse_request to the main query#

This is the pivot the whole capability matrix turns on. wp() calls WP::main(), which parses the request into query variables and then runs the main WP_Query. Only after the query runs does the wp action fire, at class-wp.php line 838. So before that line, conditional tags have nothing to read.

wp-includes/class-wp.php · php
// class-wp.php: WP::main() drives one request. parse_request() turns the URL into
// query vars, then the main WP_Query runs. Only AFTER that does the 'wp' action
// fire, which is why the queried object is null before this point.
public function main( $query_args = '' ) {
    $this->init();
    $this->parse_request( $query_args );   // URL -> query vars
    $this->query_posts();                  // the MAIN query runs here
    $this->handle_404();
    $this->register_globals();

    // class-wp.php line 838: the first hook where the main query is resolved.
    do_action_ref_array( 'wp', array( &$this ) );
}

That single fact resolves a huge share of hook confusion. Because the query runs inside WP::main() and init fires earlier in wp-settings.php, any code that reads the queried object on init sees null. Wait for the wp action, and the object is real. For the broader picture of how a URL becomes a response, see the broader anatomy of what happens when you hit a URL, which this post narrows to WordPress specifically.

The render phase opens at template_redirect, from template-loader.php line 23. By now the query is resolved, so conditional tags are reliable. Therefore template_redirect is the correct place to redirect or gate a request before a single byte prints.

wp-includes/template-loader.php · php
// template-loader.php line 23: the render gate. The main query is resolved, so
// conditional tags are reliable here. This is the last chance to redirect before
// the template prints a single byte.
do_action( 'template_redirect' );

// ... template hierarchy picks the template file ...
// wp_head(), the_content(), and wp_footer() all run inside the chosen template.

After that, the chosen template runs. wp_head prints the head, the_content filters the post body, and wp_footer closes the page. Finally the shutdown action fires during teardown, after the response is sent. Every one of the thirteen hooks now has a sourced home, so the interactive matrix can index them.

What is actually available at each hook: the capability matrix, live#

Here is the core artifact. Rather than read a static table, work the model. Pick any hook and read the computed state snapshot, or pick a task and read its correct hook. The controls sit directly above the result, so the answer changes as you select. With JavaScript off, the full capability matrix still renders as a table inside the panel.

Hook lifecycle inspector: state snapshot and mis-hook failure

Pick a hook to see what is loaded there, and what breaks if you mis-hook.

initLoad phase
wp-settings.php line 780

State available at this hook

  • Not yetMain query builtget_queried_object() usable
  • AvailableCurrent user resolvedcurrent_user_can() reliable
  • Not yetConditional tagsis_page() / is_single() real
  • Not yetPost types queryableregistered CPTs available
  • Not yetEnqueue window openassets reach wp_head
Still not resolved

The main query has not run, so is_single() and get_queried_object() are not usable yet. Types you register here become queryable only after init finishes.

Mis-hook here and you get

get_queried_object() returns null because the main query runs later, in WP::main(). is_page() returns false.

Show the full capability matrix (all 13 hooks)
What is loaded at each core hook, in firing order (WordPress core, trunk)
HookMain queryCurrent userConditional tagsCPTs queryableEnqueue window
muplugins_loadednonononono
plugins_loadednonononono
after_setup_themenonononono
initnoyesnonono
wp_loadednoyesnoyesno
parse_requestnoyesnoyesno
wpyesyesyesyesno
template_redirectyesyesyesyesno
wp_enqueue_scriptsyesyesyesyesyes
wp_headyesyesyesyesyes
the_contentyesyesyesyesno
shutdownyesyesyesyesno
Use the segmented control to switch between Browse by hook and Find by task. In hook mode, each of the thirteen lifecycle hooks reports whether the main query, the current user, the conditional tags, the registered post types, and the enqueue window are available, plus the concrete broken output from hooking there wrongly. In task mode, each task returns its correct hook and what breaks one stage too early or too late. Every fact traces to WordPress core, trunk. The full matrix is available as a table for no-JavaScript readers.

Watch the state chips flip as you step down the rail. Before the wp action, the query chip reads not yet, so is_single() is unreliable. After it, the chip flips to available and the conditional tags become trustworthy. Consequently the inspector turns hook selection into a lookup rather than a guess.

Why the WordPress request lifecycle sequence is load-bearing#

The order is not arbitrary, and understanding why is what top results miss. Take the load hooks in turn. muplugins_loaded runs first because must-use plugins cannot be deregistered, so they establish invariants other code relies on. plugins_loaded runs next, once every active plugin is in memory. after_setup_theme runs once the theme has declared its supports. init runs last of the four, when everything is present but nothing is queried.

Task to correct hook: the decision table#

This is the forward lookup the reframe promises. Read each row as a rule. The correct-hook column is the answer, and the last column is the cost of getting it wrong. In practice, most WordPress bugs in this area are one row read incorrectly.

Common tasks mapped to the correct hook, the earliest safe point, and what breaks off-slot
TaskCorrect hookEarliest safe pointWhat breaks off-slot
Register a custom post typeinitinitToo early: extensions on init miss it. Too late: the archive 404s and REST omits it.
Add a rewrite ruleinitinitOff init the rule is not generated for this request; it needs a flush on the next load.
Enqueue front-end CSS or JSwp_enqueue_scriptswp_enqueue_scriptsOn init the queue is not ready. On the_content wp_head already printed, so the tag never ships.
Read the queried objectwpwpOn init get_queried_object() returns null because the main query has not run.
Redirect on request statetemplate_redirecttemplate_redirectOn init is_single() is false. After the head sends, wp_redirect() warns headers already sent.
Modify the main querypre_get_postspre_get_postsOn init no query exists. On wp the query already ran, so changes have no effect.
Wrap the post body outputthe_content (priority 12)priority 12At priority 10 you wrap literal shortcode text, not the expanded HTML.

The last row is the one that costs the most time, so it earns a full walkthrough next. Because nothing in the API signature warns you about it, the only defense is knowing the firing order.

One integer between correct and broken output: the the_content priority chain#

Here is the thesis proved with real numbers. A plugin author ships a [stock_price] shortcode and wants to wrap all post-body output in a highlight div. So they register a the_content filter with the default signature. That default resolves to priority 10, per wp-includes/plugin.php. And priority 10 is where the trouble starts.

The callbacks core runs on the_content, in priority order#

Core registers its own callbacks on the_content, and they execute in ascending priority. Read the chain below from wp-includes/default-filters.php. The number that matters is 11, because that is where shortcodes expand.

wp-includes/default-filters.php · php
// wp-includes/default-filters.php (trunk): core's own callbacks on the_content,
// with their priorities. They run in ASCENDING priority order. Read the numbers,
// because they decide whether your filter sees shortcode text or shortcode OUTPUT.
add_filter( 'the_content', 'do_blocks', 9 );
add_filter( 'the_content', 'wptexturize' );          // default priority 10
add_filter( 'the_content', 'wpautop' );              // default priority 10
add_filter( 'the_content', 'shortcode_unautop' );    // default priority 10
add_filter( 'the_content', 'prepend_attachment' );   // default priority 10
add_filter( 'the_content', 'do_shortcode', 11 );     // shortcodes expand HERE
add_filter( 'the_content', 'wp_filter_content_tags', 12 );
add_filter( 'the_content', 'convert_smilies', 20 );
  1. 8

    apply_block_hooks_to_content_from_post_object

    Block-hook insertion from the post object.

  2. 9

    do_blocks

    Gutenberg renders block markup to HTML.

  3. 10

    wptexturize, wpautop, shortcode_unautop, prepend_attachment

    Default priority. Formatting and paragraph wrapping run here. A filter with no priority argument lands in this group.

  4. 11

    do_shortcode

    Shortcodes expand to their output. Before this point the content still holds literal shortcode text.

  5. 12

    wp_filter_content_tags

    Image and iframe tags are processed. Your wrapper belongs right here, just after expansion.

  6. 20

    convert_smilies

    Late formatting pass. Later than you want for a body wrapper.

Now the failure is obvious. A wrapper at priority 10 runs before do_shortcode at priority 11. Therefore, at that moment, the content still contains the literal string "[stock_price]". The wrapper wraps raw shortcode text, and the rendered price never appears.

The broken wrapper vs the fixed wrapper#

The fix is a single integer. Switch between the two variants below. The only difference is the priority argument, yet the output flips from broken to correct.

php
// BROKEN. add_filter with no priority argument defaults to priority 10, per
// wp-includes/plugin.php. So this runs BEFORE do_shortcode (priority 11). At this
// moment $content still holds the literal string "[stock_price]", not its output.
add_filter( 'the_content', 'wrap_output' );

function wrap_output( string $content ): string {
    // $content here is:  "<p>Today: [stock_price]</p>"
    // You wrap the raw shortcode TEXT. The rendered price never appears.
    return '<div class="highlight">' . $content . '</div>';
}

That is the whole lesson in one diff. Because priority 10 runs before shortcode expansion and priority 12 runs after it, the same function produces broken or correct output depending on one number. Nothing in add_filter warns you. Only the firing order does, which is exactly why the order is worth learning.

The corollary: get_queried_object() on init returns null#

The same lifecycle reasoning explains a second classic bug. get_queried_object() reads the main query. The main query runs inside WP::main(), which wp() invokes after wp-settings.php fires init. So on init the query has not run, and the object is null.

inc/queried-object.php · php
// The lifecycle-number corollary. get_queried_object() reads the main query, and
// the main query runs inside WP::main(), which wp() invokes from
// wp-blog-header.php AFTER wp-settings.php fires init (line 780). So on init the
// query has not run yet.
add_action( 'init', function () {
    $obj = get_queried_object();   // null on init, every time
    // is_single() also returns false here, because there is no query to ask.
} );

// Wait for the 'wp' action (class-wp.php line 838) or 'template_redirect'
// (template-loader.php line 23), where the query is resolved.
add_action( 'template_redirect', function () {
    $obj = get_queried_object();   // now a real WP_Post, term, or user
} );

Hook multiplicity and the lifecycle forks#

Two more gaps trip up otherwise-correct code. First, some hooks fire more than once. Second, some code silently never runs because the request forked down a path that skips its hook. Both follow directly from the lifecycle, so both are predictable once you see the map.

template_redirect fires once; the_content fires per loop iteration#

Where the request forks: front-end, admin, REST, AJAX, WP-CLI, cron#

The boot path above is the front-end path. But the same core serves several entry points, and they diverge after init. Consequently, front-end render code hooked to template_redirect never runs for an admin, REST, AJAX, WP-CLI, or cron request. This branching is genuinely different from the linear boot flow, so it earns a second diagram.

Where the WordPress request lifecycle forks by entry point: front-end, admin, REST, AJAX, WP-CLI, and croninit fires for every entry point. After that, a public URL runs the front-end render path with the wp action, template_redirect, and the_content. An admin request fires admin_init and never runs template_redirect. REST fires rest_api_init, AJAX fires wp_ajax hooks and dies early, WP-CLI bootstraps to init with no HTTP render, and cron fires scheduled hooks with no query or current user. Front-end render hooks fire only on the front-end branch.

Read the fork diagram as a warning. Because init is shared but the render hooks are not, cross-context code must hook a stage every path reaches, such as init or plugins_loaded. Otherwise it works on the front end and silently no-ops in the admin, in REST, or under WP-CLI.

When not to reach for a hook, or this whole model#

Hooks are not always the right tool, and the lifecycle model is not always the right lens. Knowing where it stops is part of using it honestly.

Keep reading: the WordPress internals cluster#

This post sits in a small cluster on how WordPress actually works underneath. If timing is your interest, then two neighbours go deeper on data and encoding. Read why WordPress still leans on PHP serialization instead of JSON for how core stores structured values, and read how WordPress's data layer decides between postmeta and custom tables for the read-pattern trade-offs behind that storage.

For the software engineering practice around the lifecycle, two more posts apply. See structuring a WordPress codebase at scale with Composer and PSR-4 for how autoloaded classes attach to these hooks, and see hooking into that same lifecycle to build an AI content workflow without extra plugins for the hooks in practice. For the primary sources, read the WordPress action reference and the actions handbook, then the core files themselves: wp-settings.php, class-wp.php, and default-filters.php.

Why did my hook not fire? WordPress lifecycle debugging questions

Why did my hook not fire?
First, check that the code registering the callback is itself loaded before the hook fires. A callback added on init from inside a template file loads too late, because the template runs long after init. Second, confirm the request even reaches that hook. A hook on template_redirect never fires for a REST, AJAX, cron, or WP-CLI request, because those entry points do not run the front-end render path.
Why is my plugin data empty at this hook (get_queried_object returns null on init)?
Because the main query has not run yet. get_queried_object() reads the resolved main WP_Query, and that query runs inside WP::main(), which fires after init. Therefore on init the object is null and is_single() returns false. Move the read to the wp action or to template_redirect, where the query is resolved.
When does init fire in WordPress?
init fires from wp-settings.php (line 780 in trunk), after must-use plugins, regular plugins, and the theme functions.php have all loaded. So the current user is resolvable and every plugin is in memory. However, the request has not been parsed into a query yet, so conditional tags are not usable at init.
What is the difference between template_redirect and wp_head?
template_redirect fires once, before any output, from template-loader.php. It is the correct place to redirect or short-circuit a request. wp_head fires later, while the head section prints, from general-template.php. Therefore you cannot redirect on wp_head, because headers and body have already started to send.
muplugins_loaded vs plugins_loaded: which runs first?
muplugins_loaded runs first, then plugins_loaded. Must-use plugins load and fire muplugins_loaded before regular plugins are required. Consequently a must-use plugin can register a filter that a regular plugin later depends on. That ordering is fixed, so you pick the stage that matches what must already be in place.
Does the_content fire more than once per request?
Yes. the_content is a filter on post body content, so it runs once per loop iteration, and it can also run inside widgets, blocks, and REST responses. Therefore a side effect placed in a the_content filter can fire many times on one page. By contrast, template_redirect fires exactly once, which makes it safe for a single action.

Debugging a hook that fires at the wrong stage, or planning where a plugin should attach to the WordPress request lifecycle? No pressure and no lock-in.

Talk through a WordPress build

Maulik Macwan

Software Engineer, Atyantik Technologies

Hi, I am a Senior Software Engineer at Atyantik, skilled in Laravel, Livewire, and Filament, with strong knowledge of Core PHP, WordPress, and CodeIgniter. Experienced in payment gateway and API integrations, including OpenAI APIs and Docker. I follow PSR-12 standards, use Git, and work in Agile development. A quick learner, problem solver, and reliable team player dedicated to delivering quality solutions.

More from Maulik MacwanCMS platform engineeringHire WordPress developers

Keep reading