A practical guide to the WordPress request lifecycle and the hooks that fire along the way. This is the lesson that retires the most common bug in WordPress development: “I added my code and nothing happened.” Nine times out of ten the code is fine. The timing is wrong.
The Core Idea
Every time someone visits a WordPress site, the request goes through a sequence of stages: load core, load plugins, load the theme, figure out what URL was requested, run the matching query, pick a template, render the page, send it back. At each stage WordPress fires hooks so plugins and themes can hop in and do their work.
If you attach to a hook that has already fired, your code is dead on arrival. If you attach to a hook that fires before the data you need exists, you get nulls or wrong answers. So before you ever attach a hook, you should be able to answer: at what point in the request do I need this to run?
The Big Six Hooks for Front-End Requests
There are dozens of hooks in the lifecycle. These six are the ones you will reach for daily.
| Order | Hook | What is loaded by now | What you would do here |
|---|---|---|---|
| 1 | plugins_loaded | All active plugins are loaded into memory. WP core is up. The current user is not known yet. | A plugin announces itself, registers its add-ons, sets up internal state. |
| 2 | after_setup_theme | The active theme’s functions.php has run. | Theme support declarations: add_theme_support( 'post-thumbnails' ), image sizes, navigation menus. |
| 3 | init | All plugins and the theme have booted. The current user is known. | Register custom post types, taxonomies, shortcodes, REST routes, custom rewrite rules. The most-used hook in WordPress. |
| 4 | wp_loaded | Everything that needed init has had its turn. | Fire-once setup that depends on CPTs already existing. |
| 5 | template_redirect | WordPress has parsed the URL and decided which template to load. The query is resolved, you can call is_singular(), is_page( 'pricing' ), etc. | Custom redirects, force login, force HTTPS, intercept form submissions. |
| 6 | wp_footer | Page is fully rendered, just before </body>. | Inject scripts, tracking pixels, debug output. |
There is also shutdown which fires after the response has been sent to the browser. Useful for logging, useless for anything that needs to affect the page.
The Mental Model
Think of a request as a relay race with batons being handed off:
plugins_loaded → after_setup_theme → init → wp_loaded →
parse_request → wp → template_redirect → template_include →
wp_head → THE PAGE → wp_footer → shutdown
Three rules drop out of this picture, and they explain about half of all “my code did not run” bugs you will ever encounter.
Rule 1: Register things on init or earlier
Custom post types, taxonomies, shortcodes, REST routes, rewrite rules. If you register them later, the request has already moved past the point where WordPress looks for them, and you get 404s or “shortcode not found”.
// Correct
add_action( 'init', function() {
register_post_type( 'book', array( /* args */ ) );
} );
// Too late. Will not appear in the admin or on the front-end consistently.
add_action( 'wp_loaded', function() {
register_post_type( 'book', array( /* args */ ) );
} );
Rule 2: Decide things based on the query on template_redirect or later
Before template_redirect, WordPress does not yet know what page you are on. Calling is_page( 'pricing' ) inside init returns wrong results because the query has not been resolved.
// Correct
add_action( 'template_redirect', function() {
if ( is_page( 'account' ) && ! is_user_logged_in() ) {
wp_redirect( wp_login_url() );
exit;
}
} );
// Wrong. is_page() is not reliable this early.
add_action( 'init', function() {
if ( is_page( 'account' ) ) { /* ... */ }
} );
Rule 3: Output things on wp_footer, wp_head, or inside the template
Echoing during init or plugins_loaded will sometimes throw “headers already sent” errors, because cookies or redirects have not been finalised yet. The body of the response has not started. Save your output for the right hook.
// Correct
add_action( 'wp_footer', function() {
echo '<!-- analytics tag -->';
} );
// Wrong, and may break redirects elsewhere.
add_action( 'init', function() {
echo '<!-- analytics tag -->';
} );
The Admin Side Is a Different Race
When the request is for /wp-admin/, the lifecycle is different. The hooks you care about:
admin_init— admin equivalent ofinit. Register settings, handle form submissions to admin pages.admin_menu— register admin menu items and submenus here, never earlier.admin_enqueue_scripts— load admin-only CSS and JS.admin_notices— render warning banners at the top of admin screens.
A common confusion: init fires on both front-end and admin requests. admin_init fires only on admin. So if you accidentally register a custom post type on admin_init, the front-end never sees it. You hit this and you spend an hour wondering where your post type went.
// Wrong. CPT only exists in the admin. Front-end shows 404.
add_action( 'admin_init', function() {
register_post_type( 'book', array( /* ... */ ) );
} );
// Correct. CPT exists everywhere.
add_action( 'init', function() {
register_post_type( 'book', array( /* ... */ ) );
} );
A Real Pattern: Force Login on a Specific Page
Customers ask for “redirect anyone who is not logged in away from this page” all the time. The right hook is template_redirect, because by then the query is resolved and we know which page we are on.
add_action( 'template_redirect', function() {
// Skip the login page itself, otherwise we get a redirect loop.
if ( is_page( 'login' ) ) {
return;
}
// Apply the rule only to the account page.
if ( is_page( 'account' ) && ! is_user_logged_in() ) {
wp_redirect( home_url( '/login' ) );
exit;
}
} );
The exit after wp_redirect is non-negotiable. Without it, WordPress keeps running, eventually outputs HTML, and the redirect header is ignored or produces a “headers already sent” warning.
Try writing the same thing on init and you will find is_page() returns false for everything, because the query has not been built yet. Same code, wrong hook, completely broken.
The Lab: See the Order With Your Own Eyes
This is a five-minute exercise that will pin the lifecycle into your memory permanently.
Step 1. Open wp-content/themes/your-active-theme/functions.php and paste this at the bottom:
foreach ( array(
'plugins_loaded',
'after_setup_theme',
'init',
'wp_loaded',
'parse_request',
'wp',
'template_redirect',
'wp_head',
'wp_footer',
'shutdown',
) as $hook ) {
add_action( $hook, function() use ( $hook ) {
error_log( 'HOOK FIRED: ' . $hook );
}, 1 );
}
Step 2. Make sure debug logging is on. Open wp-config.php and confirm (or add) these lines:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
The log file will be at wp-content/debug.log.
Step 3. Reload the front page of your site once.
Step 4. Open debug.log. You will see:
HOOK FIRED: plugins_loaded
HOOK FIRED: after_setup_theme
HOOK FIRED: init
HOOK FIRED: wp_loaded
HOOK FIRED: parse_request
HOOK FIRED: wp
HOOK FIRED: template_redirect
HOOK FIRED: wp_head
HOOK FIRED: wp_footer
HOOK FIRED: shutdown
That is the WordPress request lifecycle laid out in front of you, in the order it actually runs.
Step 5. Now reload wp-admin. Look at the log again. You will see template_redirect, wp_head, and wp_footer are missing or replaced (admin uses admin_head and admin_footer instead). This is the front-end vs admin difference made visible.
Step 6. Remove the test code when done.
Cheat Sheet: Picking the Right Hook for a Job
| You want to… | Use this hook |
|---|---|
| Register a custom post type or taxonomy | init |
| Register a shortcode or REST route | init |
| Add theme support (post thumbnails, menus) | after_setup_theme |
| Run code that depends on a logged-in user | init (or later) |
| Redirect or block a page based on its slug | template_redirect |
| Enqueue CSS/JS for the front-end | wp_enqueue_scripts |
| Enqueue CSS/JS for the admin | admin_enqueue_scripts |
| Add an admin menu item | admin_menu |
| Show an admin notice | admin_notices |
Inject content into <head> | wp_head |
Inject content before </body> | wp_footer |
| Log or clean up after the response is sent | shutdown |
Closing Thought
When developers say “I know WordPress hooks,” they usually mean they know add_action and add_filter. That is the syntax. The deeper skill is knowing when each hook fires and which one matches the job at hand. That knowledge is what stops you spending an afternoon debugging a registration that runs at the wrong time.
Print the order. Tape it to your monitor. Two weeks of writing hooks with that sheet next to you and you will have it memorised for life.
Leave a Reply