Every ability you’ve registered so far in this track has been a closed unit: you wrote
the permission_callback, you wrote the execute_callback, and nobody else’s code was
involved. That’s fine for a single plugin, but once an ability library is used by
other teams, agencies, or third-party add-ons, it needs the same extensibility contract
WordPress itself has trained developers to expect: filters on arguments before
registration, actions after key lifecycle events, and a documented, stable surface
other code can hook into without editing your files. This lesson applies that
convention to abilities specifically.
This lesson assumes you’ve registered abilities before (Courses 1 and 2) and are
comfortable with add_filter() / apply_filters() and add_action() / do_action()
as core WordPress conventions. If those are unfamiliar, review the Plugin API handbook
first.
Step 1: Filter the arguments array before you register
The single highest-leverage extension point is filtering the arguments array itself,
right before it reaches wp_register_ability(). This lets an extension tighten a
permission check, add a field to a schema, or attach extra meta without ever touching
your plugin’s source:
// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
$args = array(
'label' => __( 'List support tickets', 'my-plugin' ),
'description' => __( 'Returns open support tickets for the current site.', 'my-plugin' ),
'category' => 'support',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'status' => array( 'type' => 'string', 'enum' => array( 'open', 'closed' ) ),
),
),
'output_schema' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ),
'permission_callback' => function () {
return current_user_can( 'edit_others_posts' );
},
'execute_callback' => 'my_plugin_list_tickets',
'meta' => array( 'mcp' => array( 'public' => true ) ),
);
/**
* Filters the arguments for my-plugin/list-tickets before registration.
*
* @param array $args Standard wp_register_ability() argument array.
*/
$args = apply_filters( 'my_plugin_ability_args_list_tickets', $args );
wp_register_ability( 'my-plugin/list-tickets', $args );
} );
An extension plugin now needs zero knowledge of your file layout to, say, restrict this ability to a stricter capability for enterprise installs:
// File: my-plugin-enterprise-addon.php
add_filter( 'my_plugin_ability_args_list_tickets', function ( $args ) {
$args['permission_callback'] = function () {
return current_user_can( 'manage_options' );
};
return $args;
} );
Step 2: Fire actions around the lifecycle, not just registration
Filters change data; actions announce moments. Give extensions both. A do_action()
right after your base abilities are registered lets add-ons register abilities that
depend on yours existing first, without race conditions over hook priority:
// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
// ... register my-plugin/list-tickets, my-plugin/get-ticket, etc.
/**
* Fires after my-plugin's core abilities are registered.
*
* Extensions should register any abilities that depend on my-plugin's
* categories or abilities during this action, not directly on
* wp_abilities_api_init, to guarantee ordering.
*/
do_action( 'my_plugin_abilities_registered' );
}, 10 );
This is the same pattern WooCommerce uses with woocommerce_loaded, and the same
reason core fires after_setup_theme separately from init: dependent code needs a
guaranteed “the thing I depend on is done” signal, not a priority-number guessing game.
Step 3: Let extensions shape the result, not just the request
Filtering only the input side is half the contract. A genuinely extensible ability also
filters its own output before returning it from execute_callback, so an add-on can
enrich a result (add a computed field, redact a value for a lower permission tier)
without re-implementing the whole query:
// File: my-plugin.php
function my_plugin_list_tickets( array $input ) {
$tickets = my_plugin_query_tickets( $input['status'] ?? 'open' );
/**
* Filters the tickets array returned by my-plugin/list-tickets.
*
* @param array $tickets Raw ticket data before returning to the caller.
* @param array $input The original ability input.
*/
return apply_filters( 'my_plugin_list_tickets_result', $tickets, $input );
}
| Hook type | When it fires | Typical use by a third party |
|---|---|---|
| Filter on args (pre-registration) | Just before wp_register_ability() | Tighten permission_callback, extend a schema, flip meta.mcp.public |
| Action after registration | End of your wp_abilities_api_init callback | Register dependent abilities in the right order |
| Filter on execute result | Inside execute_callback, before return | Enrich, redact, or reshape output for a specific integration |
| Filter on category args | Inside wp_abilities_api_categories_init | Rename or re-describe a category for white-labeling |
Step 4: Document a namespace contract
Namespacing (my-plugin/list-tickets) already prevents two unrelated plugins from
colliding. A reusable base plugin should go one step further and document a
sub-namespace convention for its own add-ons, for example requiring official extensions
to prefix abilities with my-plugin-{addon}/. This is a documentation and code-review
discipline, not something the Abilities API enforces for you, but it’s exactly the kind
of contract that keeps a base plugin’s ability list legible once five different add-ons
are installed alongside it.
Test it
Register the base ability and the enterprise add-on filter from Step 1 in two separate files (or two mu-plugins), then confirm the filter actually took effect:
wp-env run cli wp eval '
$ability = wp_get_ability( "my-plugin/list-tickets" );
var_dump( $ability->has_permission( array() ) );
'
With only the base plugin active, this reflects edit_others_posts. With the
enterprise add-on’s filter also active, confirm it now requires manage_options
instead, by testing as a user who has one capability but not the other.
Because wp_register_ability() runs during wp_abilities_api_init and the filtered
array is baked into the registered WP_Ability object, a filter added after that
action has already fired for that request does nothing. Extensions must hook their
filters on plugins_loaded or earlier, well before wp_abilities_api_init runs, not
inside it themselves unless they also control ordering with hook priority.
Recap
Extensible abilities follow the same conventions WordPress has used for two decades:
filter the arguments before you register, fire an action once registration is done so
dependents can build on top, and filter your own output before returning it. None of
this is Abilities API machinery, it’s just apply_filters() and do_action() applied
deliberately around wp_register_ability() calls, which is exactly why it composes
cleanly with everything else in this track.
Resources & further reading
- Plugin API / Hooks handbook, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- wp_register_ability() function reference, developer.wordpress.org