Registration Errors

Fixing Hook-Timing and Load-Order Errors

⏱ 14 min

Lesson 2 covered the two shapes of the wrong-hook mistake: registering outside wp_abilities_api_init entirely, and attaching your listener too late for the one-shot window. This lesson goes one level deeper, into the cases where your code is technically on the right hook, but a dependency, a shared category, or another plugin’s own registration needs to happen before or after yours within that same request. This is where hook priority becomes the actual fix mechanism, not a vague “try adjusting numbers until it works” exercise.

What you'll learn in this lesson
Why order matters within a single hook
Multiple plugins hooking the same action, and what happens when your code assumes a specific order.
Using priority to guarantee a dependency runs first
A category or a shared registration another plugin's ability relies on.
Defensive guards with did_action() and doing_action()
Detecting a too-late registration attempt instead of silently failing.
A safe pattern for registering across multiple plugin files
When abilities are split across several files or classes.
Prerequisites

Lesson 2 and Lesson 3 in this module. This lesson assumes you already understand the one-shot nature of wp_abilities_api_init and are dealing with an ordering problem between multiple callbacks on that same hook, not a wrong-hook mistake.

Step 1: Why order within the hook can matter at all

wp_abilities_api_init fires once, but every plugin that hooks it runs in priority order, default priority 10, lower numbers first. Most of the time this doesn’t matter, your ability doesn’t depend on anyone else’s. It starts mattering the moment one plugin’s ability registration depends on a category, a shared constant, or an object that another plugin is responsible for setting up on the very same hook. If your callback happens to run before the dependency is ready, you get exactly the same silent null covered in Lesson 2, except this time the cause isn’t your own mistake, it’s an assumption about ordering that turned out to be wrong.

// File: plugin-a.php, registers a shared category other plugins can use
add_action( 'wp_abilities_api_categories_init', function () {
	wp_register_ability_category( 'shared-integrations', array(
		'label' => __( 'Shared Integrations', 'plugin-a' ),
	) );
}, 10 );
// File: plugin-b.php, registers an ability in that category
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability( 'plugin-b/do-something', array(
		'category' => 'shared-integrations', // depends on plugin-a having registered this
		// ...
	) );
}, 20 ); // deliberately after plugin-a's default priority

Categories are registered on wp_abilities_api_categories_init, which fires before wp_abilities_api_init entirely, so a category from another plugin is always available by the time any ability registration runs, regardless of priority within wp_abilities_api_init itself. The priority concern in the example above is illustrative of the general pattern: when two callbacks on the same hook have a real dependency between them, priority is the mechanism for guaranteeing order, not luck.

Step 2: Using priority deliberately, not as a guess

If you do have two callbacks on wp_abilities_api_init where one genuinely depends on work the other does inside that same hook (for example, one callback builds a reusable object both abilities’ execute_callbacks close over), give the dependency an explicitly lower priority number and document why:

// File: my-plugin.php
add_action( 'wp_abilities_api_init', 'my_plugin_bootstrap_shared_state', 5 );
add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities', 20 );

The gap between 5 and 20 is deliberate, it leaves room for something else to insert a callback between them later without needing to renumber anything. Treat priority numbers the same way you’d treat spacing in a migration’s version numbers: leave room.

Don't reach for priority to fix a wrong-hook mistake

If your ability registration is failing because you’re on init instead of wp_abilities_api_init at all, no priority number fixes that, you’re not late within the right hook, you’re not on the right hook. Confirm you’re actually dealing with an ordering problem between two callbacks on the same correct hook before reaching for priority as the fix. Lesson 2 covers the wrong-hook case specifically.

Step 3: Defensive guards for load-order assumptions you can’t control

When your ability’s registration depends on another plugin that you don’t control (an optional integration, not a hard dependency), don’t assume it ran first just because its priority number is lower. Check directly:

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	if ( ! wp_get_ability_categories() || ! isset( wp_get_ability_categories()['shared-integrations'] ) ) {
		// The category we depend on isn't there yet or ever. Register our own fallback,
		// or skip this ability entirely, rather than calling wp_register_ability()
		// with a category argument that doesn't exist and getting a silent null back.
		return;
	}
	wp_register_ability( 'my-plugin/optional-integration', array(
		'category' => 'shared-integrations',
		// ...
	) );
} );

This turns a silent null into a deliberate, explained skip, and it’s the same principle as checking class_exists() before relying on an optional dependency anywhere else in WordPress plugin development.

For the specific one-shot-window problem from Lesson 2, where your own add_action( 'wp_abilities_api_init', ... ) call might be getting attached after the hook already fired, did_action() gives you a direct way to detect it rather than guess:

// File: my-plugin.php
function my_plugin_attach_ability_registration() {
	if ( did_action( 'wp_abilities_api_init' ) ) {
		error_log( 'my-plugin: attached to wp_abilities_api_init too late, this request will not register abilities.' );
		return;
	}
	add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' );
}
my_plugin_attach_ability_registration();

This doesn’t fix a load-order problem by itself, but it turns an invisible failure into a logged, actionable one, which is most of the value in a debugging workflow.

Step 4: A safe pattern when abilities span multiple files

Plugins that split ability registrations across several files or classes sometimes hit this by splitting the add_action() calls apart too, with each file separately hooking wp_abilities_api_init. That’s fine on its own, WordPress runs every callback attached to a hook regardless of which file attached it. The actual risk is when one of those files is conditionally loaded (behind a feature flag, an active-plugin check, or an autoloader that only includes it on some requests) in a way that could delay when its add_action() call itself happens. Keep the loading of any file that hooks wp_abilities_api_init unconditional and early, and put the conditional logic inside the callback itself, not around the add_action() call.

Test it

# File: terminal, confirm your dependency actually registered before yours does
wp eval '
$categories = wp_get_ability_categories();
foreach ( array_keys( $categories ) as $slug ) {
	echo $slug . PHP_EOL;
}
'

Then re-run the registration health check from Lesson 2 to confirm your dependent ability is actually present, not just that no error appeared.

Recap

Most hook-timing failures are the wrong-hook or too-late mistakes from Lesson 2. The remaining, subtler class is a genuine ordering dependency between two callbacks that are both correctly attached to wp_abilities_api_init, and priority numbers, spaced deliberately and documented, are the real fix mechanism for that case. Categories themselves are never part of this problem since wp_abilities_api_categories_init always fires first. Use did_action() guards to turn a silent too-late failure into a logged one, and keep any file that attaches this hook loaded unconditionally.

Resources & further reading

← The WooCommerce Registry Conflict Input Schema Edge Cases: Empty Objects and Validation Failures →