Home Blog

, ,

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

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,…

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.

Plaintext
// 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' );
} );

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

Plaintext
// 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 );
	}
} );

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

Plaintext
// 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.
	}
} );

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

Plaintext
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' ),
	) );
} );

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

Plaintext
add_action( 'init', function() {
	register_post_type( 'portfolio', array(
		'public' => true,
		'label'  => 'Portfolio',
	) );
} );

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

Plaintext
// 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();
	}
} );

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