Development
How to Deprecate WordPress Hooks Without Breaking Extensions

WordPress hooks are public APIs even when they are not documented formally. Once another plugin, theme, or customer snippet attaches a callback, changing the hook name, timing, argument order, or filtered value can break code you do not control.
Safe deprecation introduces the replacement while the old hook still works, emits WordPress's standard deprecation signal, preserves the old contract, and gives extension authors enough release time to migrate.
Write down the existing contract
Before editing the call site, inspect the hook's history and search every repository you can access. Document:
- Exact hook name
- Whether it is an action or filter
- When it fires relative to reads, writes, and output
- Argument count, order, types, and mutability
- The filter value's meaning and expected return type
- Which callbacks run internally
- Public examples and integration documentation
- Versions that third-party extensions may still support
The function call is not the whole contract. Moving an action from before a database commit to after it can break a callback even if all arguments remain identical.
Introduce the replacement first
Choose a namespaced, durable new name. Avoid naming it after an implementation detail you already expect to change.
Suppose a plugin has an action named acme_order_paid and wants the clearer acme_order_completed. Preserve the old timing and arguments while both exist:
<?php
declare(strict_types=1);
/**
* Fires the supported and deprecated order-completion actions.
*/
function acme_fire_order_completed_hooks(int $order_id, Acme_Order $order): void
{
do_action('acme_order_completed', $order_id, $order);
do_action_deprecated(
'acme_order_paid',
[$order_id, $order],
'3.2.0',
'acme_order_completed',
'Use acme_order_completed with the same arguments.'
);
}
do_action_deprecated() both fires the old callbacks and reports that the hook is deprecated when WordPress debugging handles such notices.
Keep the bridge in one function so every call site uses the same order and arguments. Duplicating two hook calls throughout the plugin invites drift.
The emitter invokes both hook names. If an extension registers the same receipt, webhook, or accounting callback on both, that side effect runs twice. Migration therefore means moving a consumer from one registration to the other, not listening to both indefinitely.
Before the extension raises its minimum plugin version:
add_action('acme_order_paid', 'partner_send_receipt', 10, 2);
After every supported plugin version provides the replacement:
add_action('acme_order_completed', 'partner_send_receipt', 10, 2);
During a temporary mixed-version support window, select exactly one registration from a reliable plugin version constant:
$order_hook = defined('ACME_VERSION')
&& version_compare(ACME_VERSION, '3.2.0', '>=')
? 'acme_order_completed'
: 'acme_order_paid';
add_action($order_hook, 'partner_send_receipt', 10, 2);
If one consumer truly must listen to both, make the operation idempotent with a durable key such as receipt:<order-id> and a unique database constraint. An in-memory flag does not protect retries or another PHP process.
Decide and document whether the new or old action fires first. The example gives new callbacks the first observation. If existing internal behavior depends on old callbacks running before new ones, preserve that order instead. Consistency with the established lifecycle matters more than a universal preference.
Preserve a filter's value chain
Filters are more delicate because callback return values form a pipeline. Every implementation must decide whether legacy callbacks feed the new filter or vice versa.
The following example is valid only when the documented legacy contract accepts and returns a boolean. If existing callbacks may return null, an object, or another meaningful value, the parameter type, casts, and return type would narrow the public contract. Preserve the legacy value type first, then adapt explicitly at the new boundary.
If old callbacks historically establish a boolean access decision and new callbacks should see that result, use:
<?php
declare(strict_types=1);
/**
* Applies legacy and supported filters to an access decision.
*/
function acme_filter_access_decision(
bool $allowed,
int $user_id,
int $resource_id
): bool {
$allowed = (bool) apply_filters_deprecated(
'acme_can_view_resource',
[$allowed, $user_id, $resource_id],
'3.2.0',
'acme_resource_access_allowed',
'Use acme_resource_access_allowed with the same arguments.'
);
return (bool) apply_filters(
'acme_resource_access_allowed',
$allowed,
$user_id,
$resource_id
);
}
apply_filters_deprecated() returns the filtered value, so discarding its return would silently break old extensions.
Do not pass a new object where the old hook promised an integer, remove an argument, or reorder values while calling the old hook. If the new hook needs a richer contract, adapt between them explicitly and document which data cannot be represented on the legacy path.
Do not alias hooks with callbacks
It can be tempting to attach an internal callback to one hook that fires the other. That approach can change priority ordering, recursion behavior, callback inspection, and removal semantics.
Keep hook emission at the domain event's real call site or behind one dedicated emitter function. The WordPress hooks overview explains the distinction between actions, which perform work, and filters, which transform and return a value.
Add contract tests for old and new consumers
Test the extension surface, not only the final application output.
For an action bridge, verify:
- Both hook names fire exactly once.
- Each receives the documented arguments in order.
- Arguments refer to the expected values or objects.
- The firing order is stable.
- The hook fires before or after persistence as documented.
- The deprecated-hook notice identifies the replacement and version.
- A migrated side-effect consumer is registered on one hook and executes exactly once while the emitter fires both names.
For a filter bridge, also verify:
- A legacy callback can still change the returned value.
- A new callback can change the value.
- A value from the first chain reaches the second chain.
- Priorities within each hook behave normally.
- False, null, and other boundary values follow the declared contract.
<?php
declare(strict_types=1);
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class AccessHookCompatibilityTest extends TestCase
{
/**
* Removes callbacks registered by each test.
*/
protected function tearDown(): void
{
remove_all_filters('acme_can_view_resource');
remove_all_filters('acme_resource_access_allowed');
parent::tearDown();
}
/**
* Confirms that the legacy result flows into the supported filter.
*/
#[Test]
public function itPreservesTheLegacyFilterValue(): void
{
add_filter('acme_can_view_resource', '__return_true');
add_filter(
'acme_resource_access_allowed',
static function (bool $allowed): bool {
return $allowed;
}
);
self::assertTrue(acme_filter_access_decision(false, 12, 34));
}
}
Add an equivalent action test with a call counter around acme_fire_order_completed_hooks(). Register the consumer on the version-selected hook, fire the emitter once, and assert the count is exactly one. A separate emitter-contract test should still prove that one legacy observer and one new observer each receive their respective event once.
Run integration tests inside WordPress so the real hook registry and deprecation handling are involved.
Publish a migration note
The changelog entry should state:
- Old and new hook names
- First version containing the replacement
- Deprecation version
- Argument mapping and firing order
- A before-and-after extension example
- A warning that registering the same side effect on both names duplicates the operation
- Earliest possible removal version
- Where extension authors can report compatibility issues
Update inline hook documentation and developer references in the same release. A runtime notice without accessible migration documentation merely transfers debugging work to integrators.
Choose a removal policy before promising a date
Semantic versioning usually reserves removal of a public hook for a major release. That does not mean the next major release is automatically enough notice.
Base removal on evidence:
- The replacement has shipped and remained stable.
- First-party consumers have migrated.
- Public documentation uses only the replacement.
- Telemetry or ecosystem searches indicate low legacy use where available and lawful.
- Extension authors received at least one normal migration window.
- The major-release upgrade guide calls out the removal.
If usage cannot be observed, preserve the compatibility bridge longer. A tiny pair of hook calls is often cheaper than breaking an unknown integration.
Avoid these compatibility mistakes
- Firing the old and new actions at different lifecycle points
- Changing argument order on the deprecated hook
- Ignoring the value returned by a deprecated filter
- Emitting a raw PHP notice instead of the WordPress deprecation API
- Deprecating without a supported replacement
- Removing the old hook in a patch or minor release
- Updating code but not examples and reference documentation
- Keeping duplicate hook-emission logic in several call sites
Hook deprecation is a migration, not a rename. Preserve the old observable contract, introduce one clear new contract, test both paths, and remove the bridge only when versioning policy and ecosystem evidence make that safe.