Make WordPress Core

Opened 5 weeks ago

Last modified 4 weeks ago

#66097 new enhancement

Allow hook callbacks to be scoped to a Multisite site

Reported by: hokku Owned by:
Priority: normal Milestone:
Component: General Version:
Severity: normal Keywords: dev-feedback has-patch has-unit-tests
Cc: Focuses: multisite

Description (last modified by hokku)

Introduce an optional $blog_id argument to add_action(), add_filter(), has_action(), has_filter(), remove_action(), and remove_filter().

The new argument would allow a developer to register a callback for an explicitly selected site and let the hook dispatcher skip that registration when the current site does not match. Existing registrations would remain global by default. Lookup and removal would accept an optional site selector, following the existing distinction between an unspecified constraint and a specific constraint.

I ran into this while working on a Multisite project where individual sites could be connected to different external services, such as separate search indexes.

A callback registered while site A is current remains registered when another component calls switch_to_blog() and performs an operation on site B. As a result, a callback bound to site A's service can receive an event that happened while site B was current.

This ticket proposes allowing an action or filter registration to declare that it belongs to a particular site.

Problem and motivation

In Multisite, switch_to_blog() changes the current site data context, but previously loaded plugin and theme code remains loaded. Callbacks registered before the switch therefore remain active after it.

This is documented behavior and is useful for network-wide functionality. It becomes a problem when a callback belongs to a site-specific service instance, for example:

  • an external search index;
  • an integration account;
  • a notification destination;
  • a repository or API client configured for one site.

Consider a deleted_post callback connected to site A's search index. If another component switches to site B and deletes a post there, the callback can incorrectly remove an entry from site A's index. Numeric post IDs are not unique across a Multisite network, so the site context is essential.

The same issue applies to filters. A filter associated with site A may provide configuration or modify a value while site B is current.

This can currently be handled with a guard inside every callback:

$blog_id = get_current_blog_id();

add_action(
        'deleted_post',
        static function ( $post_id, $post ) use ( $blog_id ) {
                if ( get_current_blog_id() !== $blog_id ) {
                        return;
                }

                // Update the service associated with this site.
        },
        10,
        2
);

Once this behavior is known, the workaround is small: keep the guard in each callback, or centralize it in a lightweight registration wrapper or dispatcher. The latter has to be created or adopted by each project and used consistently.

The harder part is discoverability. Nothing at the point of add_action() or add_filter() indicates that a callback intended for one site will remain eligible after unrelated code switches to another site. The switch may happen in a different plugin or subsystem, so the author of the callback may not know that a guard is required, and a later callback or refactor can easily omit it.

Such an omission usually does not fail immediately. The callback receives valid-looking arguments while the wrong site is current, and the defect may only become visible after an incorrect deletion from an external index, a notification sent through the wrong account, or another cross-site side effect.

An explicit site argument addresses both sides of this problem. It makes the callback's expected execution context visible at the point of registration, and it moves enforcement of that declared constraint to WP_Hook, which already decides whether and when the callback is invoked. The developer remains responsible for selecting the intended site; the dispatcher becomes responsible for not invoking that registration while another site is current. This places enforcement in the dispatch layer instead of requiring every business callback to remember the same implicit Multisite invariant. It does not ask Core to switch or repair the current context.

Minimal example of the current behavior

The following synthetic action isolates hook dispatch from post operations or other plugin behavior:

$origin_blog_id = get_current_blog_id();
$other_blog_id  = 7; // An existing, different site.
$observed       = array();

$callback = static function () use ( &$observed ) {
        $observed[] = get_current_blog_id();
};

add_action( 'example_blog_scope_probe', $callback, 10, 0 );

do_action( 'example_blog_scope_probe' );

switch_to_blog( $other_blog_id );

try {
        do_action( 'example_blog_scope_probe' );
} finally {
        restore_current_blog();
        remove_action( 'example_blog_scope_probe', $callback, 10 );
}

With the current API, $observed contains both $origin_blog_id and $other_blog_id.

Proposed direction

Add an optional site selector to action and filter registration:

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

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

These are signature sketches and do not propose native PHP parameter types.

Existing calls without $blog_id would retain their current global behavior. A registration with an explicit site ID would be eligible only while that site is current.

The earlier example could therefore register its callback as:

add_action(
        'example_blog_scope_probe',
        $callback,
        10,
        0,
        $origin_blog_id
);

The action would still fire after switching to the other site, but this particular registration would be skipped.

The dispatcher should evaluate the current site when the callback is about to run. It should not call switch_to_blog(), load or unload plugins, or infer a site from the callback arguments.

For an initial implementation, I suggest considering a single positive site ID only. Support for multiple site IDs could be discussed separately if there is sufficient need.

Registration lifecycle

If site scope becomes part of a registration, has_action(), has_filter(), remove_action(), and remove_filter() will also need a consistent way to address that scope.

The exact API should be decided during review. In particular:

  • whether the scope becomes part of registration identity;
  • how lookup and removal distinguish a specific site, a global registration, and all scopes;
  • whether global and site-specific registrations of the same callback may coexist;
  • what should happen when the same callback and priority are registered for different sites.

I do not think these semantics should be fixed before there is agreement that site-aware registration belongs in the native hooks API.

Related Core discussions

#14941 describes the underlying Multisite behavior: callbacks from the original site's plugins remain active after switch_to_blog(). That ticket was closed because switching sites is not intended to load or unload PHP code, and plugins are expected to check the current site themselves.

This proposal does not change that behavior. It would provide an opt-in way for a plugin to declare the check as part of its subscription.

#49518 introduced text-domain-specific translation filters in addition to the existing general filters. It is a related design pattern: developers can subscribe to a narrower set of relevant events instead of handling every invocation of a general hook and checking the context inside the callback. It is not a direct precedent for storing site metadata in WP_Hook.

#64186 added priority-aware lookup to has_action() and has_filter() for consistency with registration and removal. It is relevant if site scope is treated as another registration attribute that must be handled across the hook lifecycle.

Compatibility and performance

The default path for existing registrations must remain unchanged in behavior.

The main compatibility concerns appear to be:

  • the public structure of WP_Hook::$callbacks;
  • preinitialized hook arrays;
  • wrappers that currently pass surplus positional arguments;
  • code that temporarily removes and later restores callbacks;
  • registration changes during active or nested dispatch.

There is also a performance concern. Hook dispatch is a hot path, and adding a site check to the central execution loop may affect requests that do not use scoped registrations.

An implementation should therefore benchmark at least:

  • existing global-only registrations;
  • single-site and Multisite requests;
  • mixed global and site-specific registrations;
  • native scoping compared with an equivalent callback-level guard.

This proposal does not assume that native scoping is faster. The performance cost needs to be measured against the API and correctness benefits.

Expected implementation coverage

If the general direction is accepted, an initial patch should verify:

  • unchanged behavior for registrations that do not specify a site;
  • matching and non-matching site registrations for both actions and filters;
  • switching and restoring between sites;
  • nested hook dispatch;
  • lookup and selective removal;
  • callback ordering and mutation during dispatch;
  • the all hook path and reference-array variants;
  • preinitialized hooks and early bootstrap behavior.

The detailed test matrix will depend on the registration identity and lookup/removal semantics chosen during review.

Limits of the proposal

Site scoping would only guarantee that a registration runs while its declared site is current.

It would not:

  • determine the site from which a callback argument originated;
  • correct an incorrectly restored site context;
  • load or unload plugins when switching sites;
  • provide authorization;
  • persist registrations between requests;
  • support arbitrary context predicates.

Feedback requested

The main questions are:

  1. Does Core consider site-scoped subscriptions useful enough to belong in the native hooks API, given that callback guards and wrappers already work?
  2. Is an optional $blog_id on the existing registration functions an appropriate API, or would separate helper functions be preferable?
  3. Should scope become part of registration identity?
  4. How should global and site-specific registrations be represented in lookup and removal?
  5. Is it preferable to begin with scalar site IDs only?

The intended result is a native and inspectable way to associate a callback with one Multisite site while preserving the existing global registration behavior by default.

Change History (7)

#1 @hokku
5 weeks ago

  • Description modified (diff)

#2 @hokku
5 weeks ago

  • Description modified (diff)

#3 @hokku
5 weeks ago

  • Description modified (diff)
  • Summary Add optional blog scoping to action and filter registration, lookup, and removal → Allow hook callbacks to be scoped to a Multisite site

#4 @hokku
5 weeks ago

  • Description modified (diff)

#5 @hokku
5 weeks ago

  • Description modified (diff)

#6 @mihdan
5 weeks ago

I absolutely agree with the previous speaker. A very useful feature for working with WordPress hooks. I often encounter a similar situation and constantly have to write workarounds and hacks. This enhancement would solve many problems.

This ticket was mentioned in ​PR #13550 on ​WordPress/wordpress-develop by ​@hokku.


4 weeks ago
#7

  • Keywords has-patch has-unit-tests added

This adds native site-scoped callback registrations to the Hooks API while preserving global registrations as the default.

The existing registration, lookup, and removal functions receive an optional trailing $blog_id parameter:

  • Registration accepts null for global scope or a positive integer for one site.
  • Lookup and removal accept false for all scopes, null for global scope, or a positive integer for one site.
  • Scope forms part of registration identity, allowing the same callback and priority to coexist globally and for multiple sites.
  • Global and matching site-scoped callbacks retain their normal registration order.
  • Scope is evaluated immediately before each callback, including nested dispatch and the all hook.
  • The initial implementation intentionally supports scalar site IDs only.

Legacy global callback keys and callback array shapes remain unchanged. A separate global-only dispatch path avoids adding a per-callback scope check to existing hooks.

Tests cover global compatibility, matching and non-matching sites, identity and ordering, lookup and removal selectors, priority 0, nested and mid-dispatch changes, the all hook, reference-array variants, invalid IDs, preinitialized hooks, and adding the first scoped callback during dispatch.

Use of AI Tools

AI assistance: Yes
Tool(s): OpenAI Codex
Model(s): GPT-5
Used for: API design analysis, implementation, test development, performance benchmarking, and automated QA. The contributor selected and approved the public API decisions and is submitting the resulting patch.

Note: See TracTickets for help on using tickets.