hokoo / wp-hooks-dispatcher
Context-aware WordPress action and filter subscriptions
Requires
- php: ^8.1
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
- squizlabs/php_codesniffer: ^3.13
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-13 09:17:24 UTC
README
Prevents a WordPress action or filter callback created for one site from running while a different multisite context is active.
Important
This package is a temporary workaround for a WordPress Core WP_Hook
limitation: callback registrations cannot be scoped to an individual
Multisite site. The proposed native solution is tracked in
WordPress Core Trac #66097.
Once the ticket is resolved in Core and the WordPress version containing the
fix has been released, this package will be considered obsolete. Check the
ticket status before adopting it in new projects.
The problem
The WordPress hook registry is process-global. switch_to_blog() changes the
active site and database prefix, but it does not remove or scope callbacks that
were registered earlier:
switch_to_blog(1); $siteOneCleanup = create_cleanup_service_for_the_current_site(); add_action('deleted_post', [$siteOneCleanup, 'handle']); switch_to_blog(2); // The callback belonging to site 1 is still registered and will be called. do_action('deleted_post', 42);
This matters when the callback retains a site-bound database adapter, client, cache, or other service. In a long-running worker, multisite test process, CLI command, or any application that switches sites dynamically, stale callbacks can execute against the wrong context, fail unexpectedly, or keep retired object graphs alive.
add_action() and add_filter() have no site-context boundary and return no
lifecycle handle for the registration.
What this package does
ActionDispatcher::subscribe() and FilterDispatcher::subscribe() capture
both the current WordPress blog ID and $wpdb->prefix, then register a stable
wrapper with the native WordPress hook registry. Every time the hook fires, the
wrapper checks the current values before invoking application code:
- both values match: the consumer callback runs normally;
- either value differs: the callback is skipped (and filters pass the current value through unchanged);
- the captured context is restored: delivery resumes while still subscribed;
- the subscription is removed: delivery stops permanently for that handle.
WordPress still owns hook dispatch, priority order, filter value chaining, and accepted-argument handling. Exceptions from an active callback pass through unchanged.
The package deliberately does not switch sites, create site-specific clients, or rebind existing subscriptions. The consumer remains responsible for initializing and subscribing a separate application object in every site context it uses.
When to use it
Use it for callbacks that retain site-specific state in a process that can call
switch_to_blog() after registration. Typical cases are multisite workers,
test suites, importers, queue consumers, and CLI processes.
It is usually unnecessary for a conventional isolated WordPress request that never changes site, or for a callback that is intentionally context-neutral.
Requirements
- PHP 8.1 or newer
- a loaded WordPress runtime providing the relevant
add_action()andremove_action()oradd_filter()andremove_filter()functions,get_current_blog_id(), and a string$wpdb->prefix
The package has no Composer runtime dependencies beyond PHP. Non-standard or
isolated bootstraps can inject their own ActionHookGateway or
FilterHookGateway and SiteContextProvider instead of using the native
WordPress adapters.
Install
composer require hokoo/wp-hooks-dispatcher
Use actions
Create the dispatcher after WordPress is loaded. Subscribe each site-bound callback while its intended site is active, and retain the returned handle for explicit teardown:
use iTRON\wpHooksDispatcher\ActionDispatcher; $dispatcher = new ActionDispatcher(); $subscription = $dispatcher->subscribe( 'deleted_post', static function (int $postId): void { // Work that belongs only to the site active at subscription time. }, priority: 10, acceptedArguments: 1 ); $subscription->unsubscribe(); $subscription->unsubscribe(); // Safe: unsubscription is idempotent.
The dispatcher registers its own wrapper, not the original consumer callback.
Consequently, remove_action($hook, $originalCallback) cannot remove this
registration; call unsubscribe() on the returned handle instead.
Use filters
Filter subscriptions use the same context and lifecycle rules. When the captured context is inactive, the wrapper returns the current filtered value unchanged so later callbacks continue to receive the correct value:
use iTRON\wpHooksDispatcher\FilterDispatcher; $dispatcher = new FilterDispatcher(); $subscription = $dispatcher->subscribe( 'the_title', static fn (string $title, int $postId): string => $title . ' #' . $postId, priority: 10, acceptedArguments: 2 ); $subscription->unsubscribe();
Filter subscriptions require acceptedArguments to be at least 1. The
wrapper must receive the current filtered value in order to pass it through
safely when its captured context is inactive.
As with actions, remove a managed filter through its subscription handle rather
than by passing the original consumer callback to remove_filter(). See the
complete public contract.
Development
composer install composer check composer analyse WP_CORE_DIR=/path/to/wordpress composer test:integration XDEBUG_MODE=coverage WP_CORE_DIR=/path/to/wordpress composer test:coverage
Integration tests load WordPress's real WP_Hook implementation from the
checkout identified by WP_CORE_DIR, including its native multisite
switch/restore lifecycle. The coverage command requires Xdebug or PCOV and
enforces at least 90% production line coverage.
License
MIT.