If you maintain more than one plugin that needs the same handful of abilities, order lookup, customer search, content publishing helpers, copy-pasting the registration code between them is exactly the maintenance trap plugin developers have spent two decades learning to avoid with shared libraries. Abilities are ordinary PHP registered through an ordinary WordPress action, which means they package into a reusable Composer library the same way any other shared plugin code does, with one extra wrinkle worth handling deliberately: what happens when two consuming plugins bundle the same library at different versions.
Composer familiarity (PSR-4 autoloading, composer.json) is assumed. This lesson
builds on the extension patterns from Lesson 1, a reusable library is really just
“abilities designed to be depended on,” not a new API.
Step 1: Structure the package
A minimal, real layout for an ability library, independent of any single plugin:
my-vendor/wp-support-abilities/
├── composer.json
├── src/
│ ├── Registrar.php
│ └── Callbacks/
│ └── TicketCallbacks.php
// File: composer.json
{
"name": "my-vendor/wp-support-abilities",
"description": "Reusable WordPress abilities for support ticket data.",
"type": "library",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1"
},
"autoload": {
"psr-4": { "MyVendor\\SupportAbilities\\": "src/" }
}
}
No dependency on the Abilities API itself needs declaring in Composer, since it ships
in WordPress core (6.9+); the library’s real dependency is “runs inside WordPress
core 6.9 or later,” which belongs in your plugin header or readme, not composer.json.
Step 2: Register abilities from a class, guarded against double-loading
// File: src/Registrar.php
namespace MyVendor\SupportAbilities;
class Registrar {
private static bool $registered = false;
public static function init(): void {
// Guard against two consuming plugins both bundling this library.
if ( self::$registered ) {
return;
}
self::$registered = true;
add_action( 'wp_abilities_api_init', array( self::class, 'register_abilities' ) );
}
public static function register_abilities(): void {
wp_register_ability( 'my-vendor-support/list-tickets', array(
'label' => __( 'List support tickets', 'wp-support-abilities' ),
'description' => __( 'Returns open or closed support tickets.', 'wp-support-abilities' ),
'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' => array( Callbacks\TicketCallbacks::class, 'can_view' ),
'execute_callback' => array( Callbacks\TicketCallbacks::class, 'list_tickets' ),
'meta' => array( 'mcp' => array( 'public' => false ) ),
) );
}
}
The consuming plugin’s job is just one call:
// File: my-plugin.php (a consumer of the library)
require_once __DIR__ . '/vendor/autoload.php';
add_action( 'plugins_loaded', array( \MyVendor\SupportAbilities\Registrar::class, 'init' ) );
Step 3: The real problem, two plugins, two versions of the same library
If Plugin A bundles my-vendor/wp-support-abilities version 1.2 and Plugin B bundles
version 1.4, and both are active on the same site, WordPress has no built-in
dependency resolution the way a single Composer project does. Whichever plugin’s
autoloader runs first “wins,” and it’s not guaranteed to be the newer version.
| Strategy | How it works | Trade-off |
|---|---|---|
| Class-exists guard (shown above) | First-loaded version registers; later loads are silently skipped | Simple, but an older bundled version can “win” and shadow a newer one |
| A dedicated must-use loader plugin | Site owner installs the library once, as its own mu-plugin, and consuming plugins check for it instead of bundling it | Most correct, but requires coordinating installation across every consumer |
| Version constant checks | Registrar checks a defined constant for the loaded version and only re-registers if the incoming version is newer | Solves the “older wins” problem, adds a bit of bookkeeping code |
For most teams shipping a handful of internal or client plugins, the class-exists guard combined with disciplined internal versioning (always bump every consumer together) is enough. For a library meant for wide, uncoordinated third-party reuse, the version-constant check is worth the extra code:
// File: src/Registrar.php
if ( ! defined( 'MY_VENDOR_SUPPORT_ABILITIES_VERSION' ) ||
version_compare( '1.4.0', MY_VENDOR_SUPPORT_ABILITIES_VERSION, '>' ) ) {
define( 'MY_VENDOR_SUPPORT_ABILITIES_VERSION', '1.4.0' );
// Only the numerically newest loaded copy actually registers.
}
Step 4: What “reusable” commits you to
Once a second project depends on your library, its ability names
(my-vendor-support/list-tickets) and schemas are a public contract. Renaming an
ability, changing its input_schema in a breaking way, or removing a field from
output_schema breaks every consumer silently, exactly like breaking a public function
signature would. Treat ability names and schemas with the same semver discipline you’d
apply to a public API: additive changes are a minor version bump, anything that
changes existing behavior is a major version bump, documented in a changelog a
consumer would actually read before updating.
Test it
Require the package from two separate dummy plugins on the same test site, activate both, and confirm only one registration actually ran:
wp-env run cli wp eval 'var_dump( wp_has_ability( "my-vendor-support/list-tickets" ) );'
true, with no PHP notices about a duplicate ability name, confirms the guard worked.
If both consuming plugins are managed under one Composer project (a single site
deployment with one composer.json), Composer itself resolves to one shared version
automatically, and none of this duplicate-loading logic is needed. The problem only
exists when each plugin ships its own bundled vendor/ directory independently, which
is still the overwhelmingly common case for WordPress.org-distributed plugins.
Recap
An ability library is a normal Composer package with one WordPress-specific consideration: guard against being loaded twice by two different consuming plugins, either with a simple class-exists check or a version-aware constant for wider distribution. Once other projects depend on it, its ability names and schemas are a public contract, version them like one.
Resources & further reading
- Abilities API reference, developer.wordpress.org
- Composer: Autoloading, getcomposer.org
- Plugin API / Hooks handbook, developer.wordpress.org