Declare a WordPress feature once: REST route, WP-CLI command and Ability with wp-kit
A plugin feature often needs more than one entry point. The admin screen calls a REST route, deploy scripts call a WP-CLI command, and with the Abilities API the same feature can also be registered as an Ability. Written by hand, each copy gets its own parameter list, permission check and validation, and over time they stop matching.
wp-kit (opens in a new tab) takes a different approach. You declare the operation once, with its fields, capability and output, and three adapters register it on REST, WP-CLI and the Abilities API from that single definition.
Install
composer require mammothwp/wp-kit
wp-kit is a runtime dependency, because the adapters register routes, commands and abilities inside WordPress, so ship vendor/ with your plugin. It requires PHP 7.4 or newer. Composer's autoload.files loads bootstrap.php. Several active plugins can each include their own copy, and the newest copy boots on plugins_loaded.
A minimal example
The service is plain PHP. wp-kit calls its methods and passes declared fields to parameters with the same name.
final class Things_Service
{
private static $instance;
public static function instance()
{
return self::$instance ?: (self::$instance = new self());
}
public function list_items(int $limit = 50)
{
$posts = get_posts(['numberposts' => $limit]);
return ['items' => array_map(function ($post) {
return ['id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status];
}, $posts)];
}
}
The resource is the one definition. Put it in its own file, includes/Things_Resource.php, and load it with a Composer classmap in your plugin's composer.json, then run composer dump-autoload:
"autoload": {
"classmap": ["includes/"]
}
The kit's classes exist only after plugins_loaded, so a class that extends Resource cannot be declared while your main plugin file loads. The classmap loads it on first use, inside Kit::on_ready().
use MammothWP\WpKit\{Field, Operation, Resource};
final class Things_Resource extends Resource
{
public function name() { return 'things'; }
public function service() { return Things_Service::instance(); }
public function rest_namespace() { return 'my-plugin/v1'; }
public function cli_base() { return 'my-plugin things'; }
public function ability_category() { return 'my-plugin-things'; }
public function ability_category_args() { return ['label' => 'Things', 'description' => 'Abilities for things.']; }
public function operations()
{
return [
'list' => Operation::read('list_items')
->label('List things')
->description('List things.')
->input([
Field::int('limit')->default_value(50)->max(200)->describe('Rows to return.'),
])
->rest('GET')
->cli('list', ['columns' => ['id', 'title', 'status']])
->ability('list-things'),
];
}
}
Register the adapters inside Kit::on_ready():
\MammothWP\WpKit\Kit::on_ready(function () {
$registry = (new \MammothWP\WpKit\Registry())->add(new Things_Resource())->ready();
foreach ($registry->all() as $resource) {
(new \MammothWP\WpKit\Adapters\Rest_Adapter($resource))->register();
(new \MammothWP\WpKit\Adapters\Cli_Adapter($resource))->register();
(new \MammothWP\WpKit\Adapters\Ability_Adapter($resource))->register();
}
});
WordPress requires a description on every ability and on its category, which is why the example sets both.
From that one declaration, the limit field becomes:
- a REST argument of type integer with
default50 andmaximum200, - a
[--limit=<limit>]flag in the WP-CLI synopsis and inwp help, - the same constraints in the Ability input schema.
The Ability adapter only registers when the Abilities API is available, and the CLI adapter only when WP-CLI is loaded. The capability defaults to manage_options. Override capability() on the resource, or call ->capability() on one operation.
One execution path
Every surface calls Operation::run(), which:
- normalises the input against the fields and applies defaults,
- validates it (
mmwp_invalid_param, 400), - checks the capability (
mmwp_forbidden, 403), - asks for confirmation on destructive calls where required,
- calls your service method.
Operations come in three kinds: Operation::read(), Operation::write() and Operation::destructive(). By default, a destructive operation needs confirm: true when it is called as an Ability.
Every ability the kit registers is marked meta.mcp.public = true, so an MCP server built on wordpress/mcp-adapter can list it as a tool, and the call still goes through the same checks. To keep a resource off MCP, return false from mcp() on the resource.
If an operation should not exist on one surface, say so with Operation::surfaces() or by overriding Resource::surfaces(), and give a reason. Inventory::export() carries that reason, so a test can tell a deliberate omission from a missing registration.
In a real plugin
MammothWP Acceptance Logs (opens in a new tab) on wordpress.org ships wp-kit in its vendor/ directory.
When to use it
- Your plugin exposes the same feature over REST and WP-CLI, or you plan to add Abilities.
- You want parameters, permission checks and validation defined in one place.
- You want a test to check that every operation is registered on each surface it should be.
When not to
- The feature only needs a single REST route or a single command, and you have no plans for other surfaces.
- Your route or command needs request handling that does not fit an operation with declared fields.
- You cannot ship Composer dependencies inside your plugin.
Links
- GitHub: https://github.com/mammothwp/wp-kit (opens in a new tab)
- Packagist: https://packagist.org/packages/mammothwp/wp-kit (opens in a new tab)
- License: GPLv2 or later