Registration Errors

Why wp_register_ability() Returns NULL

⏱ 15 min

wp_register_ability( string $name, array $args ): ?WP_Ability returns null on failure, and by design it does this quietly. There’s no exception, no fatal error, your plugin just keeps running as if nothing happened, until you notice the ability isn’t in wp_get_abilities() and have to work backward to find out why. This lesson covers the real, documented causes, confirmed against WordPress core’s own Abilities API documentation and against real, closed reports against the MCP Adapter, and how to catch each one immediately instead of discovering it later.

What you'll learn in this lesson
Registering outside wp_abilities_api_init
The single most common cause, and why core refuses the registration silently.
The one-shot hook window
Why registering too late looks identical to registering too early.
Duplicate names
What happens when two plugins (or two versions of yours) register the same ability name.
Missing required arguments
Which fields wp_register_ability() actually requires, and what happens when one is missing.
Prerequisites

The general debugging workflow from Lesson 1. WP_DEBUG and WP_DEBUG_LOG enabled on your local environment, since several of the failures in this lesson only surface a notice when debugging is on.

Step 1: Registering outside wp_abilities_api_init

Every ability must be registered inside the wp_abilities_api_init action. Calling wp_register_ability() directly on init, on plugins_loaded, or at the top level of a plugin file with no hook at all, is the single most common cause of a null return. WordPress core’s own documentation is explicit about this: abilities must be registered during the wp_abilities_api_init action, and calling the function from init, plugins_loaded, or any other hook triggers a _doing_it_wrong() notice and the registration is discarded.

// File: my-plugin.php, wrong
add_action( 'init', function () {
	wp_register_ability( 'my-plugin/my-ability', array( /* ... */ ) ); // returns null
} );
// File: my-plugin.php, correct
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability( 'my-plugin/my-ability', array( /* ... */ ) );
} );
The doing_it_wrong notice is invisible without WP_DEBUG

This is the trap that wastes the most time. The _doing_it_wrong() notice core emits for wrong-hook registration only appears in wp-content/debug.log when WP_DEBUG and WP_DEBUG_LOG are both enabled. On a site with debugging off, you get nothing at all, just a null return and an ability that never shows up. Always confirm debug logging is on before concluding a registration “fails for no reason.”

Step 2: The one-shot hook window, registering too late

Because wp_abilities_api_init fires once per request, there’s a second, less obvious version of the same problem: your add_action( 'wp_abilities_api_init', ... ) call itself has to be attached before WordPress fires that action. If your plugin’s bootstrap defers attaching that callback behind something that only runs later in the request (a conditional deferred to a late-priority init callback, or logic gated behind another plugin’s own late-firing hook), the action can already have fired by the time your callback is attached, and it simply never runs, WordPress does not replay hooks for late listeners. Community members debugging real registration failures have described this exact symptom: wp_register_ability() returning null with an invisible _doing_it_wrong() notice, because the one-shot registration window had already closed by the time the call happened.

// File: my-plugin.php, risky, deferred too far
add_action( 'init', function () {
	add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' );
}, 999 ); // priority 999 on init may already be too late in some load orders

Attach your wp_abilities_api_init listener directly, at your plugin’s top level or on a normal-priority plugins_loaded, not nested behind another late hook:

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

function my_plugin_register_abilities() {
	wp_register_ability( 'my-plugin/my-ability', array( /* ... */ ) );
}

Step 3: Duplicate ability names

The registry rejects a second registration under a name that’s already taken. Two plugins registering analytics/get-report, or your own plugin accidentally calling wp_register_ability() twice for the same name (easy to do if a class constructor runs more than once, or if you register the same ability from two different files by mistake), both produce the same result: the second call returns null.

# File: terminal, checking for a naming collision
wp eval '
$abilities = wp_get_abilities();
echo isset( $abilities["my-plugin/my-ability"] ) ? "already registered" : "not registered";
'
Namespace your ability names as carefully as you'd namespace a PHP function

This is the same discipline Course 1 covers for naming abilities in the first place. my-ability with no plugin prefix is a collision waiting to happen the moment a second plugin picks the same generic name. Always namespace to your own plugin or theme slug.

Step 4: Missing required arguments

wp_register_ability()’s $args array has required keys: label, description, category, execute_callback, and permission_callback. input_schema and output_schema are optional. Omitting a required field, or passing a category slug that hasn’t itself been registered yet, produces the same silent null. The next lesson is entirely about the most common, most confusing version of this specific failure: a missing category, and the real GitHub issue where it took the reporting developer, and several other people, a while to trace it back to something that simple.

Wrong hook, or no hook
Register only inside wp_abilities_api_init, never init, plugins_loaded, or top-level plugin code.
Attached too late
Attach your wp_abilities_api_init listener directly, not nested behind another hook that may fire after it.
Duplicate name
Check wp_get_abilities() for an existing key before assuming your registration is the problem.
Missing or unregistered category
category is required, and it must already exist. Covered in full next.

Test it: a registration health check you can rerun

# File: terminal
wp eval '
$name = "my-plugin/my-ability";
$result = wp_has_ability( $name );
echo "Registered: " . ( $result ? "yes" : "NO, check hook timing, duplicates, and required args" ) . PHP_EOL;
'

Then check the log for anything WordPress tried to tell you and you missed:

# File: terminal
grep -i "doing_it_wrong" wp-content/debug.log | tail -n 5

Recap

wp_register_ability() returns null on failure, silently by design. The real causes are registering outside wp_abilities_api_init (including attaching that hook itself too late in the request), a duplicate ability name already in the registry, and a missing or invalid required argument, most often a category that either wasn’t supplied or wasn’t registered yet. _doing_it_wrong() notices exist for most of these, but they’re invisible without WP_DEBUG and WP_DEBUG_LOG turned on. The next lesson walks through the specific, real GitHub issue where a missing category was mistaken for something far more exotic.

Resources & further reading

← A Repeatable Debugging Workflow for MCP Adapter Issues The WooCommerce Registry Conflict →