WordPress hooks: plugins_loaded vs. init is an important distinction whenever custom code depends on plugins, registers WordPress functionality or needs to execute at a predictable point during the WordPress bootstrap process.
Both hooks fire early.
Both are used constantly in plugins and custom snippets.
And both are frequently treated as interchangeable even though they represent different stages of WordPress initialization.
The basic difference is:
plugins_loaded
↓
all active plugins have been loaded
init
↓
WordPress has continued initializing
and plugins can register much of their runtime functionality
That distinction affects:
- plugin dependencies;
- custom post types;
- taxonomies;
- shortcodes;
- rewrite rules;
- localization;
- custom snippets;
- hook registration;
- generated PHP;
- code that depends on another plugin.
The correct question is therefore not simply:
Should I use plugins_loaded or init?
It is:
What must already exist
when my code runs,
and what does my code need to register
for later in the request?
WordPress does not load everything at once
A WordPress request passes through a sequence of initialization stages.
Very roughly, the process looks like:
request
↓
wp-config.php
↓
WordPress core bootstrap
↓
must-use plugins
↓
network plugins
↓
active plugins
↓
plugins_loaded
↓
more WordPress initialization
↓
setup_theme
↓
after_setup_theme
↓
current user initialization
↓
init
↓
wp_loaded
↓
query and request processing
↓
template / response
This is deliberately simplified.
WordPress contains many additional hooks between these stages, and different request contexts can follow different paths.
But the important point is that hooks describe moments in a lifecycle.
Choosing a hook means choosing when your code enters that lifecycle.
What is plugins_loaded?
plugins_loaded fires after WordPress has loaded the active plugins.
The official WordPress plugins_loaded hook reference describes it as firing once activated plugins have loaded.
A basic example is:
add_action( 'plugins_loaded', 'acme_bootstrap' );
function acme_bootstrap() {
// Plugin-dependent initialization.
}
At this point, code declared directly by active plugins has normally been loaded into PHP.
That makes plugins_loaded useful when your code needs to determine whether another plugin has defined a class, function or constant.
What is init?
init fires later in the WordPress initialization sequence.
The official WordPress init hook reference describes it as firing after WordPress has finished loading but before headers are sent.
A common example is:
add_action( 'init', 'acme_register_content' );
function acme_register_content() {
// Register WordPress runtime functionality.
}
init is widely used for registering things that WordPress needs during the rest of the request.
Typical examples include:
- custom post types;
- custom taxonomies;
- shortcodes;
- rewrite-related functionality;
- custom request behavior;
- some session or user-facing initialization;
- plugin runtime components.
The simplest difference
The relationship can be summarized as:
plugin files loaded
↓
plugins_loaded
↓
additional WordPress setup
↓
init
Therefore:
plugins_loaded fires before init
That sounds obvious.
The consequences are less obvious.
Why timing matters
Imagine Plugin A defines this function:
function plugin_a_do_something() {
// ...
}
Your plugin needs to use it.
If your code executes before Plugin A has been loaded, this fails:
plugin_a_do_something();
with an undefined-function error.
If you wait until plugins_loaded:
add_action( 'plugins_loaded', function () {
if ( function_exists( 'plugin_a_do_something' ) ) {
plugin_a_do_something();
}
} );
you are checking after active plugin files have been loaded.
This is one of the classic uses of plugins_loaded.
Plugin files being loaded does not mean every plugin feature is initialized
This distinction is crucial.
Suppose another plugin contains:
add_action( 'init', 'plugin_a_register_post_type' );
function plugin_a_register_post_type() {
register_post_type( 'product', $args );
}
At plugins_loaded, the function:
plugin_a_register_post_type()
may already exist.
But the actual:
register_post_type()
call has not happened yet because it is scheduled for init.
Therefore:
plugin code exists
does not necessarily mean:
plugin runtime state has been initialized
Think in terms of declarations and execution
Consider this plugin:
function acme_register_book_type() {
register_post_type( 'book', array(
'public' => true,
'label' => 'Books',
) );
}
add_action(
'init',
'acme_register_book_type'
);
When PHP loads this file:
- the function is declared;
- the callback is attached to
init; - the post type is not registered yet.
Only later, when WordPress fires:
do_action( 'init' );
does the callback execute.
This distinction between:
callback registered
and:
callback executed
is fundamental to understanding WordPress hooks.
Hooks are event registration, not immediate execution
When you write:
add_action(
'init',
'acme_register_something'
);
WordPress does not immediately execute:
acme_register_something()
Instead, you are effectively saying:
When WordPress reaches init,
call this function.
This is why registering a callback before the hook fires matters.
The “hook already fired” problem
Consider:
add_action( 'init', function () {
add_action(
'init',
'acme_second_callback'
);
} );
This is structurally suspicious.
You are registering a new callback for init while WordPress is already processing init.
Depending on priority and the internal state of the hook execution, relying on same-hook registration creates fragile behavior and should not be used as a substitute for registering the callback at the correct earlier stage.
A clearer architecture is usually:
add_action(
'plugins_loaded',
'acme_register_hooks'
);
function acme_register_hooks() {
add_action(
'init',
'acme_second_callback'
);
}
Now the sequence is explicit:
plugins_loaded
↓
register callback for init
↓
WordPress continues
↓
init fires
↓
callback executes
Why this matters for generated snippets
Generated PHP can accidentally produce timing structures such as:
add_action( 'init', function () {
add_action(
'init',
'my_generated_callback'
);
} );
The model understands that the requested WordPress feature “uses init”, but it may also wrap the generated snippet itself in an initialization hook.
The result is a callback being registered too late or at an unnecessarily confusing stage.
This is one reason Reviewing AI-Generated PHP Before Activating It should include hook timing as part of the review rather than checking only syntax and security.
TheOneWP AI Snippet Generator accounts for this distinction
TheOneWP AI Snippet Generator includes explicit hook-timing logic for generated PHP.
When generated code itself needs to register behavior for init or after_setup_theme, the generation logic avoids wrapping that registration at the same or a later lifecycle stage. Instead, it can use an earlier wrapper such as plugins_loaded so the callback exists before the target hook fires.
The intended sequence becomes:
generated snippet loaded
↓
plugins_loaded
↓
register callback
↓
init
↓
callback executes
This is a small implementation detail with a large practical effect: the generated snippet participates in the WordPress lifecycle instead of arriving after the event it wanted to listen for.
When should you use plugins_loaded?
plugins_loaded is useful when the primary question is:
Have the active plugin files been loaded?
Typical uses include:
- checking for another plugin’s class;
- checking for another plugin’s function;
- bootstrapping integration code;
- registering callbacks for later hooks;
- initializing a plugin after other active plugins are available;
- establishing plugin-to-plugin compatibility layers.
Example: initialize an integration only when a dependency exists
Suppose your plugin integrates with WooCommerce.
A common structure is:
add_action(
'plugins_loaded',
'acme_boot_woocommerce_integration'
);
function acme_boot_woocommerce_integration() {
if ( ! class_exists( 'WooCommerce' ) ) {
return;
}
// Register WooCommerce integration hooks.
}
The code waits until active plugins have been loaded before checking for the dependency.
Do not assume every dependency is ready at plugins_loaded
Checking that a class exists is different from checking that a complete subsystem is initialized.
For example:
class_exists( 'WooCommerce' )
may confirm that WooCommerce code has loaded.
It does not automatically prove that:
- the cart exists;
- the current customer exists;
- the main query exists;
- the current screen exists;
- all WooCommerce runtime hooks have executed.
The correct hook depends on what part of the dependency you actually need.
When should you use init?
init is useful when you need WordPress to register functionality that should be available during normal request processing.
Common examples include:
- registering custom post types;
- registering custom taxonomies;
- registering shortcodes;
- adding rewrite rules;
- initializing request-level functionality;
- registering certain custom endpoints or handlers through APIs designed for this stage.
Registering custom post types on init
The official register_post_type() documentation recommends registering post types on init.
A typical implementation is:
add_action(
'init',
'acme_register_book_post_type'
);
function acme_register_book_post_type() {
register_post_type(
'book',
array(
'public' => true,
'label' => 'Books',
)
);
}
By registering the content type during init, WordPress knows about it before later request processing depends on that registration.
For the broader content-model distinction, see WordPress Post Types vs. Custom Post Types.
Registering taxonomies on init
Custom taxonomies follow a similar pattern.
The official register_taxonomy() documentation covers taxonomy registration.
add_action(
'init',
'acme_register_book_genre'
);
function acme_register_book_genre() {
register_taxonomy(
'book_genre',
array( 'book' ),
array(
'public' => true,
'label' => 'Genres',
)
);
}
The important architectural idea is:
plugins_loaded
=
dependency/bootstrap stage
init
=
runtime registration stage
This is not an absolute definition of every possible use, but it is a useful mental model.
Custom post types reveal the timing difference clearly
Suppose Plugin A registers:
register_post_type( 'book', ... );
on init.
Your plugin checks this during plugins_loaded:
if ( post_type_exists( 'book' ) ) {
// ...
}
The result may be false.
Why?
Because Plugin A has loaded, but its init callback has not executed yet.
If you need to inspect a post type registered on init, your own code must run after the relevant registration has occurred.
Priorities matter inside the same hook
WordPress actions accept a priority.
The default is:
10
Lower numbers execute earlier.
Higher numbers execute later.
For example:
add_action(
'init',
'acme_register_book',
10
);
add_action(
'init',
'acme_modify_book',
20
);
The intended order is:
init priority 10
↓
register book
init priority 20
↓
modify behavior that depends on book
Hook selection answers which lifecycle stage you need.
Priority answers where your callback belongs relative to other callbacks attached to that same stage.
Do not solve every timing problem with priority 999
Developers sometimes encounter a timing issue and respond with:
add_action(
'init',
'my_callback',
999
);
This can occasionally be appropriate.
It can also be the WordPress equivalent of waiting until everyone else has left the building and hoping the room you needed is finally free.
A very high priority does not repair a fundamentally incorrect hook.
Ask first:
Am I on the correct hook?
Then ask:
Do I need to execute after another callback
on this same hook?
Plugin load order is not the same as hook priority
These are separate concepts.
Plugin files are loaded during WordPress bootstrap.
Once loaded, plugins register callbacks on hooks.
Hook priority then determines the order of callbacks attached to a particular hook.
Therefore:
plugin file load order
≠
action priority
Do not build fragile integrations by assuming another plugin’s main file happened to be included before yours.
Prefer explicit hooks and dependency checks.
What if two plugins both use plugins_loaded?
The normal priority rules apply.
add_action(
'plugins_loaded',
'plugin_a_boot',
10
);
add_action(
'plugins_loaded',
'plugin_b_boot',
20
);
Plugin A’s callback executes first because priority 10 runs before priority 20.
If Plugin B genuinely requires initialization performed by Plugin A’s plugins_loaded callback, a later priority can express that dependency.
But such dependencies should be documented because they couple the two components.
What if two plugins both use init?
The same rule applies.
Suppose Plugin A registers a taxonomy at priority 10:
add_action(
'init',
'plugin_a_register_taxonomy',
10
);
Your code needs that taxonomy to exist.
You might attach at priority 20:
add_action(
'init',
'acme_use_taxonomy',
20
);
Now the dependency is explicit within the same lifecycle stage.
Use did_action() when timing needs diagnosis
WordPress provides did_action(), which returns how many times a particular action has fired.
For debugging:
if ( did_action( 'init' ) ) {
// init has already fired at least once.
}
This can be extremely useful when diagnosing custom code loaded by an unfamiliar plugin, theme or snippet manager.
It helps answer:
Am I registering this callback
before or after the event?
Use doing_action() when you need to know whether an action is currently executing
WordPress also provides doing_action().
For example:
if ( doing_action( 'init' ) ) {
// WordPress is currently processing init.
}
This is primarily useful for debugging and specialized control flow.
It should not become an excuse for architecture where code constantly asks what WordPress is doing because it was attached at an unclear stage.
has_action() answers a different question
has_action() can determine whether a callback has been registered for an action.
That tells you:
Is this callback registered?
It does not tell you:
Has this callback already executed?
Again:
registered
≠
executed
after_setup_theme sits between important initialization stages
plugins_loaded and init are not consecutive events.
WordPress performs additional setup between them.
One important hook is:
after_setup_theme
The official after_setup_theme reference describes a stage commonly used for theme functionality.
This matters because code depending on theme setup may need to run after the theme has configured itself rather than merely after plugins have loaded.
Do not attach after_setup_theme from init
This is a classic timing mistake:
add_action( 'init', function () {
add_action(
'after_setup_theme',
'acme_theme_setup'
);
} );
By the time init fires, after_setup_theme has already happened.
The callback has missed its event.
If you need to register a callback for after_setup_theme, do so earlier.
For plugin-based code, an earlier stage such as plugins_loaded can be appropriate:
add_action(
'plugins_loaded',
function () {
add_action(
'after_setup_theme',
'acme_theme_setup'
);
}
);
This is why hook order matters more than hook names
You can memorize hundreds of WordPress hooks and still create timing bugs if you do not understand their order.
A more useful model is:
What must already have happened?
What must not have happened yet?
Which hook lies between those two points?
That reasoning scales better than memorizing isolated examples.
Conditional tags are generally not ready at plugins_loaded
Functions such as:
is_page()
is_single()
is_singular()
is_archive()
is_category()
depend on query context.
During plugins_loaded, WordPress has not yet resolved the request into the main query.
Therefore this is conceptually wrong:
add_action(
'plugins_loaded',
function () {
if ( is_page( 'contact' ) ) {
// ...
}
}
);
The function exists, but the information it needs is not ready.
Conditional Logic for WordPress Snippets explains why conditional tags must be evaluated at a stage where their request context exists.
init is still too early for some query-dependent decisions
Moving from plugins_loaded to init does not automatically make every WordPress conditional tag appropriate.
The main query is processed later.
So:
plugins_loaded
↓
too early for query conditions
init
↓
still not the universal answer
later query-aware hooks
↓
request context available
This illustrates an important principle:
later does not automatically mean late enough.
Use the hook that corresponds to the information you need
If you need:
another plugin's declared class
plugins_loaded may be appropriate.
If you need:
to register a custom post type
init is appropriate.
If you need:
the current queried page
you need a later query-aware stage.
If you need:
the current admin screen
you need an administrative stage where the screen has been established.
There is no universal “safe hook for everything”. WordPress would never make life quite that merciful.
plugins_loaded and localization
Historically, developers often used plugins_loaded for plugin localization setup.
Modern WordPress handles many translation-loading scenarios differently, and internationalization APIs have evolved over time.
The broader lesson remains useful:
do not copy a hook from an old plugin merely because that plugin used it for a similar-looking operation.
Check the current WordPress documentation for the API you are implementing.
init and shortcodes
Shortcodes can be registered during initialization.
The add_shortcode() reference documents shortcode registration.
A structured implementation might use:
add_action(
'init',
function () {
add_shortcode(
'acme_message',
'acme_render_message'
);
}
);
Again, the objective is to make the shortcode available before WordPress needs to process content containing it.
init and rewrite rules
Custom rewrite behavior is commonly registered during init.
For example:
add_action(
'init',
function () {
add_rewrite_rule(
'^library/([^/]+)/?$',
'index.php?book_slug=$matches[1]',
'top'
);
}
);
The official add_rewrite_rule() reference documents the API.
Registering a rewrite rule and flushing rewrite rules are separate operations.
You should not flush rewrite rules on every init request.
Never flush rewrite rules on every init
This is a notorious anti-pattern:
add_action(
'init',
function () {
register_post_type(
'book',
$args
);
flush_rewrite_rules();
}
);
init runs on ordinary requests.
Flushing rewrite rules repeatedly performs unnecessary expensive work.
Registration may belong on init.
Flushing usually belongs to a controlled lifecycle event such as plugin activation, after the relevant rewrite-producing functionality has been registered.
Plugin activation is a different lifecycle
Activation hooks are not substitutes for normal runtime registration.
You might have:
register_activation_hook(
__FILE__,
'acme_activate'
);
for one-time setup.
But custom post types still need to be registered during normal requests.
Activation is useful for tasks such as:
- initial setup;
- creating default options;
- creating custom database structures when genuinely necessary;
- controlled rewrite flushing;
- migration routines.
Do not confuse init with admin_init
init and admin_init are different hooks.
init participates broadly in WordPress initialization.
admin_init fires during administrative requests.
The official admin_init reference documents that backend-specific stage.
If functionality genuinely belongs only to wp-admin, admin_init may provide more appropriate scope than placing everything on global init and immediately checking:
if ( ! is_admin() ) {
return;
}
But admin_init is not an authorization check
Running inside wp-admin does not mean the current user has permission to perform every administrative operation.
Security-sensitive code should still use capabilities such as:
current_user_can()
The distinction between request context and authorization is covered in WordPress User Roles and Capabilities Explained.
init runs in more contexts than a normal frontend page
Do not think of init as:
homepage is loading
It is a WordPress initialization hook.
Depending on the request, code attached to it can participate in:
- frontend requests;
- administrative requests;
- AJAX requests;
- REST API requests;
- cron execution;
- other WordPress request contexts.
This is why expensive work should not casually be attached to init.
Do not perform expensive work simply because init is convenient
Consider:
add_action(
'init',
function () {
$response = wp_remote_get(
'https://api.example.com/sync'
);
// Process response.
}
);
This potentially introduces a remote HTTP request into a large number of WordPress requests.
The fact that init is easy to remember does not make it the right place for arbitrary work.
Ask what actually triggers the operation.
A manual action, cron event, queue or more specific hook may be much more appropriate.
Do not perform expensive work on plugins_loaded either
The same principle applies earlier.
This:
add_action(
'plugins_loaded',
function () {
rebuild_entire_search_index();
}
);
is not improved merely because it runs earlier.
plugins_loaded is a lifecycle stage, not a dumping ground for code that lacked a more specific event.
Register early, execute specifically
A strong WordPress pattern is:
early bootstrap
↓
register callbacks
↓
specific event happens
↓
perform actual work
For example:
add_action(
'plugins_loaded',
'acme_boot'
);
function acme_boot() {
add_action(
'save_post_book',
'acme_process_book'
);
}
Now the plugin establishes its behavior during bootstrap, but the expensive or state-changing operation occurs only when a book is saved.
Specific hooks are usually better than broad hooks plus conditions
If WordPress provides:
save_post_book
that can be clearer than:
save_post
↓
check post type
↓
maybe execute
Likewise, an administrative-specific hook may be clearer than global init plus several request checks.
Use the most semantically appropriate hook available.
What happens when custom code loads after init?
This is important for snippet systems and dynamically loaded code.
Imagine WordPress has already fired:
init
Then some custom mechanism evaluates:
add_action(
'init',
'my_late_callback'
);
The callback has been registered, but the event it was waiting for has already occurred.
Unless something explicitly fires init again, the callback will not receive the lifecycle opportunity it expected.
This is why the execution timing of a snippet manager matters.
Snippet systems need their own lifecycle model
A custom code manager cannot simply execute every stored PHP snippet at an arbitrary point and assume normal plugin examples will behave correctly.
If a stored snippet contains:
add_action( 'init', ... );
the manager must load that snippet before init.
If the manager waits until:
wp_loaded
then many conventional WordPress snippets become structurally late.
This is one reason custom-code infrastructure should be designed around WordPress’s lifecycle rather than merely around PHP execution.
TheOneWP Snippet Manager and execution timing
TheOneWP Snippet Manager provides a structured execution environment for custom PHP alongside CSS, JavaScript and HTML snippets.
For PHP, timing matters because the snippet may itself register WordPress callbacks.
A snippet intended to participate in init must be loaded early enough for its callbacks to be registered before WordPress reaches that stage.
Snippet scope and targeting then determine where the custom code should apply without changing the underlying WordPress hook sequence.
Conditional Logic for WordPress Snippets explains the difference between choosing an execution stage and choosing the requests on which the resulting behavior should apply.
Hook timing and conditional logic are separate dimensions
Consider:
add_action(
'wp_enqueue_scripts',
function () {
if ( ! is_singular( 'product' ) ) {
return;
}
// Product assets.
}
);
There are two decisions here.
First:
wp_enqueue_scripts
answers:
When should this code get an opportunity
to enqueue frontend assets?
Second:
is_singular( 'product' )
answers:
For which content should it do so?
Hook and condition solve different problems.
Do not move code to init merely to make a condition available
Suppose a function belongs naturally on:
wp_enqueue_scripts
Do not move the entire implementation to init simply because you are thinking about initialization.
Instead, use the hook designed for the operation.
WordPress has specific hooks because different systems become ready at different moments.
Hook timing and REST API behavior
REST requests still bootstrap WordPress and load plugins.
Code attached to broad lifecycle hooks such as plugins_loaded and init can therefore affect REST requests even if the developer originally thought only about browser-rendered pages.
This matters because WordPress itself and many plugins depend on REST functionality.
WordPress REST API Security Basics explains the security model surrounding custom endpoints and authenticated requests.
Hook timing and fatal errors
The earlier a fatal error occurs, the less of WordPress may be available to recover or render a useful response.
A fatal error directly inside a plugin file can occur while plugins are still loading.
A fatal error inside a plugins_loaded callback occurs slightly later.
A fatal error inside init occurs later still, but before normal rendering.
The affected request may be:
- frontend;
- wp-admin;
- AJAX;
- REST;
- cron.
What Happens When WordPress Hits a Fatal Error covers how those failures surface and how WordPress recovery mechanisms interact with them.
Debugging hook timing
When a callback appears not to execute, check the lifecycle rather than immediately assuming the callback itself is broken.
A useful diagnostic sequence is:
1. Is the file loaded?
2. Is add_action() actually reached?
3. Is the callback registered?
4. Has the target hook already fired?
5. Does the target hook fire in this request?
6. Is another condition returning early?
7. Is the callback failing before visible output?
Log hook execution during development
Temporary logging can make ordering visible:
add_action(
'plugins_loaded',
function () {
error_log( 'plugins_loaded fired' );
}
);
add_action(
'init',
function () {
error_log( 'init fired' );
}
);
During development, the log provides concrete evidence of execution order.
Remove unnecessary diagnostic logging before production.
Inspect did_action() during debugging
If you are uncertain when a dynamically loaded snippet executes:
error_log(
'plugins_loaded: ' .
did_action( 'plugins_loaded' )
);
error_log(
'init: ' .
did_action( 'init' )
);
If the result indicates:
plugins_loaded: 1
init: 1
then code loaded at that moment cannot meaningfully register a normal callback expecting the first init event to happen in the future.
Common mistake: checking a plugin too early
Imagine:
if ( class_exists( 'WooCommerce' ) ) {
// Integration.
}
directly inside code loaded before normal plugins.
The class may not exist yet even though WooCommerce is active.
The failure is not necessarily:
WooCommerce inactive
It may simply be:
WooCommerce has not loaded yet
Waiting for plugins_loaded can resolve that distinction.
Common mistake: checking runtime registration too early
This:
add_action(
'plugins_loaded',
function () {
if ( post_type_exists( 'product' ) ) {
// ...
}
}
);
can fail if the product post type is registered later on init.
The plugin exists.
The post type does not exist yet.
Those are different states.
Common mistake: registering a hook after it fired
This is one of the easiest mistakes to miss because the PHP itself is valid:
// Assume this code executes after init.
add_action(
'init',
'acme_callback'
);
Nothing in that line tells you it is too late.
You must know the execution context surrounding the line.
Common mistake: using init for everything
init is familiar, so it becomes tempting to use it as the universal WordPress hook.
But different tasks have more appropriate hooks.
Examples include:
wp_enqueue_scripts
admin_enqueue_scripts
admin_menu
save_post
rest_api_init
wp_footer
template_redirect
wp_login
wp_logout
Using a specific hook usually communicates intent more clearly and reduces unnecessary execution.
REST endpoints belong on rest_api_init
Custom REST routes should normally be registered using:
rest_api_init
rather than treating init as the endpoint-registration hook.
The official rest_api_init reference documents that lifecycle stage.
For example:
add_action(
'rest_api_init',
function () {
register_rest_route(
'acme/v1',
'/status',
array(
'methods' => 'GET',
'callback' => 'acme_status',
'permission_callback' => '__return_true',
)
);
}
);
Use the hook designed for the subsystem you are extending.
Admin menus belong on admin_menu
Likewise, administrative menu pages are registered through:
admin_menu
rather than init.
The official admin_menu reference documents the appropriate stage.
How to Add a Custom Admin Page in WordPress provides a complete example of building custom administrative screens with the correct capability and security model.
Frontend assets belong on wp_enqueue_scripts
CSS and JavaScript for the frontend should normally use:
wp_enqueue_scripts
rather than being printed or registered arbitrarily during init.
wp_enqueue_scripts Explained covers the enqueue lifecycle and dependency system in more detail.
Think of init as one stage, not the center of WordPress
A healthier mental model is:
plugins_loaded
plugin bootstrap
after_setup_theme
theme setup
init
general WordPress registration
rest_api_init
REST route registration
admin_menu
admin navigation
wp_enqueue_scripts
frontend assets
template_redirect
pre-template request decisions
wp_head / wp_footer
document output stages
Each hook exists because WordPress reaches a different state at that moment.
What about wp_loaded?
wp_loaded fires after WordPress, plugins and the theme are fully loaded and instantiated.
The official wp_loaded reference documents the hook.
It occurs after init.
A simplified relationship is:
plugins_loaded
↓
after_setup_theme
↓
init
↓
wp_loaded
If you need something registered for init, waiting until wp_loaded is obviously too late.
If you need a state that is established only after initialization, however, wp_loaded may be relevant.
What about muplugins_loaded?
Must-use plugins load before ordinary active plugins.
WordPress provides:
muplugins_loaded
for the stage after must-use plugins have loaded.
The official muplugins_loaded reference documents it.
The rough order becomes:
must-use plugins
↓
muplugins_loaded
↓
normal active plugins
↓
plugins_loaded
↓
init
This matters on managed hosting platforms and custom architectures where important infrastructure is implemented as must-use plugins.
Hook timing in multisite
WordPress Multisite adds additional bootstrap considerations, including network-activated plugins and site-specific state.
The general principle remains:
do not infer availability from the mere fact that your own file is executing.
Determine:
- which component provides the dependency;
- when that component loads;
- when it registers the runtime object you need;
- whether the current site or network context affects availability.
Hook timing and object-oriented plugins
A structured plugin may bootstrap from plugins_loaded:
add_action(
'plugins_loaded',
array( 'Acme_Plugin', 'boot' )
);
Then its boot method can register later callbacks:
class Acme_Plugin {
public static function boot() {
add_action(
'init',
array(
__CLASS__,
'register_content'
)
);
add_action(
'rest_api_init',
array(
__CLASS__,
'register_routes'
)
);
}
public static function register_content() {
// ...
}
public static function register_routes() {
// ...
}
}
This creates a clean distinction between:
plugin bootstrap
and:
subsystem registration
You do not always need plugins_loaded
A common overcorrection is to wrap an entire plugin in plugins_loaded even when there is no reason to do so.
If your plugin file merely registers callbacks:
add_action(
'init',
'acme_register_content'
);
that registration can often happen directly while the plugin file loads.
You do not necessarily need:
add_action(
'plugins_loaded',
function () {
add_action(
'init',
'acme_register_content'
);
}
);
unless the intermediate bootstrap stage serves a real purpose.
Direct hook registration is often simpler
This:
add_action(
'init',
'acme_register_book'
);
is easy to understand.
This:
add_action(
'plugins_loaded',
'acme_boot'
);
function acme_boot() {
add_action(
'init',
'acme_register_book'
);
}
adds another layer.
Use that layer when you need:
- dependency detection;
- controlled plugin bootstrap;
- conditional integration registration;
- a centralized initialization architecture.
Do not add lifecycle indirection merely because it looks more sophisticated.
You do not always need init either
If a task belongs to a more specific WordPress hook, use that hook.
For example:
add_action(
'wp_enqueue_scripts',
'acme_enqueue_assets'
);
does not need to be wrapped inside init.
Likewise:
add_action(
'admin_menu',
'acme_register_admin_page'
);
does not become better by first waiting for init.
A practical decision tree
When deciding between plugins_loaded, init and another hook, use this process:
Does the code need another plugin's
declared classes/functions?
|
yes
↓
consider plugins_loaded
|
↓
Does it need functionality another plugin
registers on init?
|
yes
↓
run after that registration,
possibly later on init
|
↓
Are you registering WordPress runtime
content structures?
|
yes
↓
init may be appropriate
|
↓
Does WordPress provide a more specific hook
for the subsystem?
|
yes
↓
use the specific hook
Example: dependency plus custom post type
Suppose a plugin should register a custom post type only when another plugin is active.
You could bootstrap after plugins load:
add_action(
'plugins_loaded',
'acme_boot'
);
function acme_boot() {
if ( ! class_exists( 'Required_Plugin' ) ) {
return;
}
add_action(
'init',
'acme_register_post_type'
);
}
function acme_register_post_type() {
register_post_type(
'acme_item',
array(
'public' => true,
'label' => 'Items',
)
);
}
The lifecycle now communicates the dependency clearly:
plugins load
↓
verify dependency
↓
register init callback
↓
init
↓
register post type
Example: integration with a post type registered by another plugin
Suppose another plugin registers product at init priority 10.
Your integration needs the post type to exist.
add_action(
'init',
'acme_integrate_product_type',
20
);
function acme_integrate_product_type() {
if ( ! post_type_exists( 'product' ) ) {
return;
}
// Product integration.
}
Here plugins_loaded would be too early to inspect the runtime registration.
The dependency is not merely:
plugin file loaded
but:
post type registered
Example: condition that must wait beyond init
Suppose you need to add behavior only to a specific singular page.
This is not a reason to inspect is_page() during plugins_loaded or early init.
Instead, choose a later hook appropriate for the operation.
For frontend assets:
add_action(
'wp_enqueue_scripts',
function () {
if ( ! is_page( 'contact' ) ) {
return;
}
// Enqueue contact-page assets.
}
);
Now both the operation and the condition are evaluated at an appropriate stage.
Testing hook timing
Timing-sensitive code should be tested outside production first.
Verify:
- normal frontend requests;
- wp-admin;
- REST requests;
- AJAX requests;
- cron behavior where relevant;
- dependency enabled;
- dependency disabled;
- different plugin versions where compatibility matters.
WordPress Staging Site Best Practices provides a broader process for validating code changes without turning the live site into an involuntary experiment.
Test with the dependency disabled
If your code integrates with another plugin, deactivate that plugin in staging.
The expected result should be intentional.
Possible outcomes include:
integration quietly disabled
or:
administrator receives a dependency notice
What should not happen is:
fatal error because a class disappeared
Test activation order
Consider:
- your plugin active before the dependency is activated;
- dependency active before your plugin;
- dependency deactivated while your plugin remains active;
- dependency reactivated later.
A robust integration should not rely on the administrator activating plugins in one magical sequence that was never documented.
Test updates too
A dependency can remain active while changing:
- class names;
- hooks;
- initialization timing;
- API signatures;
- registered objects.
Checking only:
class_exists()
does not prove API compatibility.
For important integrations, version compatibility and documented APIs matter too.
Hook timing checklist
Before choosing plugins_loaded, init or another hook, verify:
- what the callback actually needs to accomplish;
- which WordPress subsystem it belongs to;
- which dependencies must already be loaded;
- whether those dependencies are merely declared or fully initialized;
- whether another plugin registers required functionality later;
- whether the current user needs to be initialized;
- whether query information is required;
- whether the current admin screen is required;
- whether the target hook has already fired;
- whether priority matters relative to another callback;
- whether the code runs unnecessarily on REST, AJAX or cron requests;
- whether a more specific WordPress hook exists;
- whether expensive work is being attached to a broad lifecycle hook;
- whether dependency failures are handled safely;
- whether the implementation has been tested with dependencies disabled.
plugins_loaded vs. init: quick comparison
| Question | plugins_loaded | init |
|---|---|---|
| Have active plugin files loaded? | Yes | Yes |
| Does it fire first? | Yes | No |
| Good for checking plugin classes/functions? | Often | Possible, but later than necessary in many cases |
| Good for registering callbacks for init? | Yes | Usually too late or unnecessarily fragile |
| Typical place for custom post type registration? | No | Yes |
| Typical place for taxonomy registration? | No | Yes |
| Is the main query ready? | No | Not generally for normal query-dependent conditional logic |
| Are plugin files available? | Yes | Yes |
| Does another plugin’s init callback already exist? | Registered, if its file registered it | Depends on priority and execution point |
The most useful mental model
Think of plugins_loaded as a point where you can say:
The active plugin code is now on the table.
I can inspect dependencies
and prepare my integration.
Think of init as a point where you can say:
WordPress is entering normal runtime initialization.
I can register content structures
and functionality needed later in the request.
Then remember that neither hook is appropriate for everything.
Related guides
- Conditional Logic for WordPress Snippets
- Reviewing AI-Generated PHP Before Activating It
- WordPress Post Types vs. Custom Post Types
- WordPress REST API Security Basics
- What Happens When WordPress Hits a Fatal Error
- WordPress Staging Site Best Practices
Final recommendation
Choose WordPress hooks according to lifecycle requirements rather than habit.
Use plugins_loaded when your code needs active plugin files to exist, when you are bootstrapping an integration or when you need to register callbacks before later WordPress hooks fire.
Use init when you need to register runtime functionality that belongs to that stage, such as custom post types, taxonomies and other initialization behavior.
But do not reduce the decision to a two-hook contest. If WordPress provides a more specific hook such as rest_api_init, admin_menu, wp_enqueue_scripts or a content-specific action, that hook will often express the requirement more accurately.
Most timing bugs come from confusing four different states:
file loaded
callback registered
hook fired
runtime object initialized
Those are not the same thing.
TheOneWP Snippet Manager provides a structured execution environment for custom snippets, while AI Snippet Generator explicitly accounts for hook timing when generated PHP needs to register callbacks for later WordPress lifecycle stages.
The right WordPress hook is the earliest appropriate moment when everything your code needs already exists and everything it must influence has not happened yet.

