Opens in a new tab
  1. Home
  2. Guides
  3. Backend
Backend guide

WordPress hooks: plugins_loaded vs. init

Learn the difference between the WordPress plugins_loaded and init hooks, how plugin loading and runtime initialization differ, why hook timing and priority matter, and how to avoid registering callbacks after the event they depend on has already fired.

  • Updated September 18, 2026
  • 25 min read
  • WordPress guide

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:

  1. the function is declared;
  2. the callback is attached to init;
  3. 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

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.

Simplify your WordPress stack

A modular WordPress toolkit. 104 focused tools.

Ultimately, you can build cleaner workflows, maintain fewer plugins and enable only the features each website actually needs.