WordPress Hook Firing Order: Why Your Code Did Not Run

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.

OrderHookWhat is loaded by nowWhat you would do here
1plugins_loadedAll 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.
2after_setup_themeThe active theme’s functions.php has run.Theme support declarations: add_theme_support( 'post-thumbnails' ), image sizes, navigation menus.
3initAll 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.
4wp_loadedEverything that needed init has had its turn.Fire-once setup that depends on CPTs already existing.
5template_redirectWordPress 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.
6wp_footerPage 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 of init. 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 taxonomyinit
Register a shortcode or REST routeinit
Add theme support (post thumbnails, menus)after_setup_theme
Run code that depends on a logged-in userinit (or later)
Redirect or block a page based on its slugtemplate_redirect
Enqueue CSS/JS for the front-endwp_enqueue_scripts
Enqueue CSS/JS for the adminadmin_enqueue_scripts
Add an admin menu itemadmin_menu
Show an admin noticeadmin_notices
Inject content into <head>wp_head
Inject content before </body>wp_footer
Log or clean up after the response is sentshutdown

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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *