index.php to rendered page, one request
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.
- Boot
muplugins_loadedwp-settings.php - Load
plugins_loadedwp-settings.php - Load
setup_themewp-settings.php - Load
after_setup_themewp-settings.php - Load
initwp-settings.php:780 - Load
wp_loadedwp-settings.php - Query
parse_requestclass-wp.php - Query
wpclass-wp.php:838 - Render
template_redirecttemplate-loader.php:23 - Render
wp_enqueue_scriptswp_head path - Render
wp_headgeneral-template.php - Render
the_contentpost-template.php - Teardown
shutdownload.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.
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: 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: 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: 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.
// 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.
template_redirect to wp_head to the_content to wp_footer to shutdown#
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.
// 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.
initLoad phasewp-settings.php line 780State 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
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.
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)
| Hook | Main query | Current user | Conditional tags | CPTs queryable | Enqueue window |
|---|---|---|---|---|---|
| muplugins_loaded | no | no | no | no | no |
| plugins_loaded | no | no | no | no | no |
| after_setup_theme | no | no | no | no | no |
| init | no | yes | no | no | no |
| wp_loaded | no | yes | no | yes | no |
| parse_request | no | yes | no | yes | no |
| wp | yes | yes | yes | yes | no |
| template_redirect | yes | yes | yes | yes | no |
| wp_enqueue_scripts | yes | yes | yes | yes | yes |
| wp_head | yes | yes | yes | yes | yes |
| the_content | yes | yes | yes | yes | no |
| shutdown | yes | yes | yes | yes | no |
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.
| Task | Correct hook | Earliest safe point | What breaks off-slot |
|---|---|---|---|
| Register a custom post type | Correct hookinit | Earliest safe pointinit | What breaks off-slotToo early: extensions on init miss it. Too late: the archive 404s and REST omits it. |
| Add a rewrite rule | Correct hookinit | Earliest safe pointinit | What breaks off-slotOff init the rule is not generated for this request; it needs a flush on the next load. |
| Enqueue front-end CSS or JS | Correct hookwp_enqueue_scripts | Earliest safe pointwp_enqueue_scripts | What breaks off-slotOn init the queue is not ready. On the_content wp_head already printed, so the tag never ships. |
| Read the queried object | Correct hookwp | Earliest safe pointwp | What breaks off-slotOn init get_queried_object() returns null because the main query has not run. |
| Redirect on request state | Correct hooktemplate_redirect | Earliest safe pointtemplate_redirect | What breaks off-slotOn init is_single() is false. After the head sends, wp_redirect() warns headers already sent. |
| Modify the main query | Correct hookpre_get_posts | Earliest safe pointpre_get_posts | What breaks off-slotOn init no query exists. On wp the query already ran, so changes have no effect. |
| Wrap the post body output | Correct hookthe_content (priority 12) | Earliest safe pointpriority 12 | What breaks off-slotAt 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 (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 ); - 8
apply_block_hooks_to_content_from_post_object
Block-hook insertion from the post object.
- 9
do_blocks
Gutenberg renders block markup to HTML.
- 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.
- 11
do_shortcode
Shortcodes expand to their output. Before this point the content still holds literal shortcode text.
- 12
wp_filter_content_tags
Image and iframe tags are processed. Your wrapper belongs right here, just after expansion.
- 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.
// 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>';
} // FIXED. One integer. Priority 12 runs AFTER do_shortcode (priority 11), so the
// shortcode has already expanded and $content holds real HTML. Nothing in the
// add_filter signature warned you: the firing order is the only thing that did.
add_filter( 'the_content', 'wrap_output', 12 );
function wrap_output( string $content ): string {
// $content here is: "<p>Today: $128.40</p>"
// Now you wrap the rendered OUTPUT, which is what you wanted.
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.
// 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.
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?
Why is my plugin data empty at this hook (get_queried_object returns null on init)?
When does init fire in WordPress?
What is the difference between template_redirect and wp_head?
muplugins_loaded vs plugins_loaded: which runs first?
Does the_content fire more than once per request?
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