DEV Community

Mammoth WP
Mammoth WP

Posted on Originally published at mammothwp.com

Declare a WordPress feature once: REST route, WP-CLI command and Ability with wp-kit

This is a cross-post of our guide on mammothwp.com. The code and behaviour below are taken from that page.

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.

mammothwp/wp-kit 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
Enter fullscreen mode Exit fullscreen mode

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 short 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)];
    }
}
Enter fullscreen mode Exit fullscreen mode

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/"]
}
Enter fullscreen mode Exit fullscreen mode

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'),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
});
Enter fullscreen mode Exit fullscreen mode

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 default 50 and maximum 200,
  • a [--limit=<limit>] flag in the WP-CLI synopsis and in wp 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:

  1. normalises the input against the fields and applies defaults,
  2. validates it (mmwp_invalid_param, 400),
  3. checks the capability (mmwp_forbidden, 403),
  4. asks for confirmation on destructive calls where required,
  5. 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 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

Questions or corrections are welcome in the comments. We make wp-kit at Mammoth WP.

Drafted with AI assistance and reviewed by the MammothWP team.

Top comments (0)