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.
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.
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
- Plugin API, Hook priority, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- wp_register_ability() function reference, developer.wordpress.org
- MCP Adapter repository, GitHub