DEV Community

Cover image for The Window of Opportunity: When Each WordPress Bootstrap Hook Is Open, and When It’s Already Closed
Anatoliy Dovgun
Anatoliy Dovgun

Posted on

The Window of Opportunity: When Each WordPress Bootstrap Hook Is Open, and When It’s Already Closed

The Problem

register_post_type() called in plugins_loaded — and the site crashes with a fatal error, because taxonomies aren't ready yet. Or add_theme_support( 'post-thumbnails' ) called in init — no error, thumbnails just quietly never show up, because WordPress has already passed the point where a theme declares feature support. Or a plugin's text domain gets loaded after translatable strings have already been output, and the translation simply doesn't take.

// Too early: register_post_type() in plugins_loaded — taxonomies and other
// plugins' dependencies aren't ready yet, fatal error or unpredictable behavior.
add_action( 'plugins_loaded', function() {
    register_post_type( 'portfolio', array( 'public' => true ) );
} );

// Too late: WordPress has already passed the point where a theme
// declares feature support — the call is simply ignored, no error at all.
add_action( 'init', function() {
    add_theme_support( 'post-thumbnails' );
} );
Enter fullscreen mode Exit fullscreen mode

Both examples are correct code in the wrong hook.

Why This Is Dangerous

This isn't always a reproducible bug. Whether it fires or not often depends on plugin load order, theme version, or whether object caching is active. The classic situation: "works on my local," but on the client's production it silently doesn't — with nothing in the error log. It's hard to debug precisely because the symptom points nowhere near the cause: a missing featured image thumbnail looks like a theme problem, when the real issue is a hook called one step too late.

Step-by-Step Solution — 5 Windows, in Order

Each of these five hooks opens a narrow window for a specific class of actions. Miss your window, and the action either fails outright (closed too early) or silently does nothing (closed for good).

Step 1 — muplugins_loaded: the earliest window, before any regular plugin

// The earliest point in the entire WordPress lifecycle — before plugins, before the theme.
// Used for network-wide/security settings that can't be disabled from wp-admin
// (mu-plugins don't even have a "Deactivate" button).
add_action( 'muplugins_loaded', function() {
    if ( ! defined( 'FORWP_SECURITY_BASELINE' ) ) {
        define( 'FORWP_SECURITY_BASELINE', true );
    }
} );
Enter fullscreen mode Exit fullscreen mode

Step 2 — plugins_loaded: plugins are loaded, the theme isn't yet

// The right place for a text domain and for checking dependencies between
// plugins — the plugins themselves are already loaded, so class_exists() is safe here.
add_action( 'plugins_loaded', function() {
    load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );

    if ( ! class_exists( 'WooCommerce' ) ) {
        return; // Dependency missing — disable the feature instead of throwing a fatal error.
    }
} );
Enter fullscreen mode Exit fullscreen mode

Step 3 — after_setup_theme: the one correct place to declare theme feature support

add_action( 'after_setup_theme', function() {
    add_theme_support( 'post-thumbnails' );
    add_theme_support( 'title-tag' );
    register_nav_menus( array(
        'primary' => __( 'Primary Menu', 'my-theme' ),
    ) );
} );
Enter fullscreen mode Exit fullscreen mode

Step 4 — init: the environment is ready, safe to register content

add_action( 'init', function() {
    register_post_type( 'portfolio', array(
        'public' => true,
        'label'  => 'Portfolio',
    ) );
} );
Enter fullscreen mode Exit fullscreen mode

Step 5 — wp_loaded: everything is loaded, the request hasn't been parsed yet

// The last safe moment before request-specific logic begins — plugins and the
// theme are fully loaded, but WordPress hasn't parsed the actual request yet.
add_action( 'wp_loaded', function() {
    if ( isset( $_GET['forwp_debug'] ) && current_user_can( 'manage_options' ) ) {
        forwp_dump_registered_post_types();
    }
} );
Enter fullscreen mode Exit fullscreen mode
Hook Window open for Too early (before this hook) Too late (after this hook)
muplugins_loaded Network-wide/security settings that can't be disabled from the admin No earlier hook exists — this is the start Plugins have already begun loading — too late for "non-toggleable" code
plugins_loaded Text domain loading, checking dependencies between plugins No plugins exist yet — class_exists() is always false The theme has already begun loading, some checks lose their point
after_setup_theme add_theme_support(), register_nav_menus() The theme isn't hooked up yet WordPress has already passed the point of registering theme features — silently ignored
init register_post_type(), register_taxonomy(), shortcodes Taxonomies/dependencies aren't ready — fatal error Still possible later, but some systems (permalinks) may have already gone around it
wp_loaded Logic that needs a fully ready environment, before the request is parsed Plugins or the theme haven't finished loading everything The request is already being parsed — too late for environment setup

When It's Better to Bring In a Specialist

Five hooks look like a simple table when you have one plugin and one theme. On a real project with dozens of plugins, each hanging code off its own bootstrap hook, a load-order bug becomes nearly impossible to reproduce on your own: it works in one environment and breaks in another, with no error message anywhere. Auditing load order is exactly the case where a WordPress developer with hands-on experience in the bootstrap sequence finds the cause in minutes, because they've seen this exact picture dozens of times before. This is one of the clearest cases where WordPress development services pay for themselves — the trial-and-error cost of hunting this kind of bug alone is usually far higher than the cost of a specialist's consultation.

FAQ

Why does register_post_type() sometimes throw a fatal error, and other times just "not work"?
It depends on how early the hook fires. In plugins_loaded, taxonomies and other plugins' dependencies aren't ready yet — a fatal error is possible. In init, everything is in place — it's just too late for some related systems (permalinks, for example, may have already gone around it).

Can I call add_theme_support() in init instead of after_setup_theme?
Technically the call won't throw an error, but part of WordPress has already checked for theme feature support before init and didn't see it — the feature simply doesn't activate. after_setup_theme is the one reliable place for this.

Why does muplugins_loaded even exist if there's already plugins_loaded?
muplugins_loaded fires earlier and, more importantly, applies to code placed in mu-plugins — code that can't be disabled from the admin. That's a fundamentally different level of trust and control, which is why it gets its own, earliest hook.

Is there a hook between plugins_loaded and after_setup_theme?
Yes, setup_theme — but it isn't documented in the catalog yet (a known gap). For most tasks this doesn't matter, since after_setup_theme covers nearly every typical theme-setup scenario.

Why does the same code work locally but break in production?
Most often — a different plugin load order between environments, different theme versions, or object caching masking the problem on one server while it surfaces on another. The symptom is the same either way: code running in the wrong window.

When is it worth hiring a WordPress developer to audit load order?
When the project scales up — a new plugin or theme update suddenly "breaks" functionality that used to work, with nothing obvious in the logs. That's a typical sign of a load-order conflict, and an experienced developer checks the bootstrap chain first, before looking anywhere else.

Summary

A full breakdown of each of the 5 hooks in this category — with examples and a hands-on IDE — is on the Bootstrap Hooks page, and the whole set is also available as one PDF to keep.

Top comments (0)