Category: Uncategorized

  • 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.

  • WordPress Hooks: The Override Superpower

    A practical guide to actions and filters for anyone moving from “I install plugins” to “I extend and fix plugins.” This guide is useful for WordPress developers, support engineers, and product teams who need to understand how plugins can be customized safely.

    Why Hooks Matter

    Picture this customer request: “I want an email field to reject anyone using a @gmail.com address.”

    Without hooks, your options are risky:

    1. Edit the plugin file directly. This works until the next plugin update removes your change.
    2. Fork the plugin. Now you must maintain your own version forever.
    3. Use fragile runtime hacks. These are hard to maintain and may not work on many hosting setups.

    With a hook, you can add the change in a small custom plugin or snippet. The change can survive plugin updates because you are not editing the original plugin files.

    That is the real value of hooks. They let you change behavior safely without fighting WordPress or the plugin.

    The Two Main Tools: add_action() and add_filter()

    The difference is simple.

    Filter means WordPress or a plugin asks, “What should this value be?” You receive a value, change it if needed, and return it.

    Action means WordPress or a plugin announces, “Something happened.” You can run your own code at that moment. You do not return anything.

    The function signatures look similar:

    add_filter( $hook_name, $callback, $priority = 10, $accepted_args = 1 );
    add_action( $hook_name, $callback, $priority = 10, $accepted_args = 1 );

    Two parameters are easy to misunderstand:

    • $priority controls the order. Lower numbers run first. The default is 10.
    • $accepted_args tells WordPress how many arguments your callback can receive. The default is 1.

    For filters, always return the value. If you forget to return it, the value may become empty or broken.

    A Real Filter Example

    Let’s say a plugin provides a validation filter before accepting a field value. You want to block Gmail addresses from an email field.

    add_filter(
        'my_plugin_field_validation',
        'block_gmail_addresses',
        10,
        4
    );
    
    function block_gmail_addresses( $result, $value, $form, $field ) {
    
        // Only run this check on email fields.
        if ( $field->type !== 'email' ) {
            return $result;
        }
    
        // If the value ends with @gmail.com, fail validation.
        if ( str_ends_with( strtolower( $value ), '@gmail.com' ) ) {
            $result['is_valid'] = false;
            $result['message']  = 'Please use a non-Gmail email address.';
        }
    
        return $result;
    }

    Important details:

    • We used 4 as the accepted arguments because the hook passes four values.
    • We check the field type first, so the code only runs for email fields.
    • We return $result every time because this is a filter.

    This changes the behavior without editing the plugin file. That makes it safer during updates.

    How to Override Someone Else’s Hook

    Sometimes another plugin, theme, or custom code already added a callback to a hook. You may want to remove it or replace it.

    remove_filter( 'my_plugin_before_render', 'some_addon_callback', 10 );

    To replace it, remove the old callback and add your own callback:

    remove_filter( 'my_plugin_before_render', 'some_addon_callback', 10 );
    add_filter( 'my_plugin_before_render', 'my_replacement_callback', 10, 4 );

    To remove a filter, WordPress needs the same hook name, the same callback, and the same priority.

    Trap 1: The Priority Must Match

    If the original code used priority 20, this will not remove it:

    remove_filter( 'my_plugin_before_render', 'some_addon_callback' );

    Why? Because remove_filter() uses priority 10 by default.

    The correct version must use the original priority:

    remove_filter( 'my_plugin_before_render', 'some_addon_callback', 20 );

    This is why developers often search the plugin source for the matching add_filter() or add_action() call.

    Trap 2: Class Methods Need the Same Object

    A class method callback may look like this:

    add_filter( 'my_plugin_before_render', array( $this, 'before_render' ), 10, 4 );

    To remove it, you need the same object instance that added it. This can be hard if the plugin does not expose that object.

    Sometimes the plugin has a singleton method or public instance you can use. If not, removing that callback becomes more difficult and needs a case-by-case approach.

    What If There Is No Hook?

    Start with the safest options first.

    1. Search More Carefully

    Search the plugin code for:

    apply_filters
    do_action

    Many plugins have useful hooks that are not obvious from the settings screen.

    2. Check for Pluggable Functions

    Some WordPress functions are declared only if they do not already exist:

    if ( ! function_exists( 'wp_mail' ) ) {
        function wp_mail( /* ... */ ) {
            // Default behavior.
        }
    }

    Because of function_exists(), you can define your own version earlier, and WordPress will use yours. This is an older pattern and is not as flexible as hooks, but it still exists in some places.

    3. Accept That There May Be No Extension Point

    If a plugin does not include a hook where you need one, you cannot add that hook from the outside. A hook is a real line of code inside the plugin.

    Your better options are:

    • Use a nearby hook and check the condition you care about.
    • Use a public function or class from the plugin if it exposes one safely.
    • Ask the plugin author to add a hook.

    The safest rule is simple: extend through hooks when possible. If no hook exists, avoid hacking plugin files unless there is no other choice.

    Creating Your Own Hooks

    If you are building a plugin, you can create hooks so other developers can customize your work.

    A filter lets someone change a value:

    $greeting = apply_filters( 'my_plugin_greeting', 'Hello' );
    echo $greeting;

    An action lets someone react to an event:

    do_action( 'my_plugin_user_signed_up', $user_id, $signup_meta );

    Good hook design rules:

    1. Prefix hook names. Use something like my_plugin_ to avoid conflicts.
    2. Pass useful context. If someone needs the user ID and metadata, pass both.
    3. Document the hook. A hook that nobody can find is almost the same as no hook.
    4. Use filters for values and actions for events. Do not mix the pattern.
    5. Do not break the hook signature after release. Add a new hook if needed.

    Lab Findings

    1. The Filter Chain Works Like an Assembly Line

    Each callback receives the value returned by the previous callback.

    $title → callback at priority 10 → callback at priority 11 → final $title

    Lower priority runs first. Higher priority runs later. The later callback gets the final chance to change the value.

    Example:

    add_filter( 'the_title', function( $title ) {
        return strtoupper( $title );
    }, 10, 1 );
    
    add_filter( 'the_title', function( $title ) {
        return strtolower( $title );
    }, 11, 1 );

    The priority 10 callback changes the title to uppercase first. The priority 11 callback runs after that and changes it to lowercase. So the final title becomes lowercase.

    2. Anonymous Functions Are Hard to Remove

    This callback cannot be removed easily later:

    add_filter( 'the_title', function( $title ) {
        return strtoupper( $title );
    }, 10, 1 );

    The reason is simple: remove_filter() needs to identify the same callback. An anonymous function has no name you can pass later.

    Use a named function if you may need to remove it:

    function make_title_uppercase( $title ) {
        return strtoupper( $title );
    }
    
    add_filter( 'the_title', 'make_title_uppercase', 10, 1 );
    remove_filter( 'the_title', 'make_title_uppercase', 10 );

    Or store the anonymous function in a variable:

    $uppercase_title_filter = function( $title ) {
        return strtoupper( $title );
    };
    
    add_filter( 'the_title', $uppercase_title_filter, 10, 1 );
    remove_filter( 'the_title', $uppercase_title_filter, 10 );

    3. Class Methods Need the Same Instance

    A method callback can look like this:

    class TitleFormatter {
        public function make_uppercase( $title ) {
            return strtoupper( $title );
        }
    }
    
    $formatter = new TitleFormatter();
    
    add_filter( 'the_title', array( $formatter, 'make_uppercase' ), 10, 1 );

    To remove it, use the same object instance:

    remove_filter( 'the_title', array( $formatter, 'make_uppercase' ), 10 );

    If you create a new object, WordPress will not treat it as the same callback.

    Function Callback vs Method Callback

    A function is standalone:

    function make_title_uppercase( $title ) {
        return strtoupper( $title );
    }
    
    add_filter( 'the_title', 'make_title_uppercase', 10, 1 );

    A method belongs to a class:

    class TitleFormatter {
        public function make_uppercase( $title ) {
            return strtoupper( $title );
        }
    }
    
    $formatter = new TitleFormatter();
    
    add_filter( 'the_title', array( $formatter, 'make_uppercase' ), 10, 1 );

    Simple rule:

    • Function = standalone callback.
    • Method = callback inside a class.

    Cheat Sheet

    TaskCode
    Hook into an existing eventadd_action( 'wp_footer', 'my_callback' );
    Modify a valueadd_filter( 'the_title', 'my_callback' );
    Return from a filterreturn $title;
    Same-priority orderThe callback added first runs first.
    Remove someone else’s filterremove_filter( 'hook', 'their_callback', $their_priority );
    Create your own filter$value = apply_filters( 'my_plugin_value', $default );
    Create your own actiondo_action( 'my_plugin_event', $context );
    Accept more hook argumentsUse the 4th argument in add_action() or add_filter().
    Find available hooksSearch plugin files for apply_filters and do_action.

    Closing Thought

    Hooks are not just a WordPress feature. They are a safe agreement between the original developer and anyone who wants to extend the code later.

    When you know how to find hooks, use hooks, remove callbacks, and design your own hooks, you stop treating WordPress as a closed tool. You start working with it like a developer.