Migration & Strategy

Building Reusable Ability Libraries

⏱ 15 min

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.

What you'll learn in this lesson
Structuring an ability library as a Composer package
PSR-4 autoloading, a clean public entry point, no assumptions about how it's loaded.
Registering abilities from inside a library, not a plugin root file
The action hook still applies; the calling context doesn't.
Handling duplicate-load collisions
What happens when two plugins bundle the same library at different versions.
Versioning and compatibility guarantees
What "reusable" actually commits you to long-term.
Prerequisites

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.

Strategies for the duplicate-version problem
StrategyHow it worksTrade-off
Class-exists guard (shown above)First-loaded version registers; later loads are silently skippedSimple, but an older bundled version can “win” and shadow a newer one
A dedicated must-use loader pluginSite owner installs the library once, as its own mu-plugin, and consuming plugins check for it instead of bundling itMost correct, but requires coordinating installation across every consumer
Version constant checksRegistrar checks a defined constant for the loaded version and only re-registers if the incoming version is newerSolves 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.

Composer's own version resolution doesn't apply across separate plugins

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

← Enterprise AI Strategy: Data Governance and Vendor Agnosticism MCP Adapter Limitations and Workarounds →