Grav 2.0 Developer Guide: API Integration for Plugins

How to add custom endpoints to the Grav API from your own plugin

14 mins

The Grav API plugin is the load-bearing component of Grav 2.0. Admin Next is built on top of it, the MCP server runs through it, and any third-party tool that wants to talk to a Grav site (a build script, a CI job, a headless frontend, a mobile app, an AI agent) goes through it. The API ships as a first-party plugin in the 2.0 package, and once your plugin opts in, it gets a clean, authenticated, permission-checked, REST-y way for the rest of the world to reach it.

This post is the developer's-eye view of that. I want to walk through exactly how a plugin registers its own endpoints, what you get for free when you do, and what a complete worked example looks like end to end. It's deliberately code-heavy. If you've never touched the API plugin before, by the time you finish reading this you should be able to ship a clean, well-documented endpoint of your own.

The full reference lives on the Learn site at Plugin API Integration, and the broader API developer guide is at API Developer Guide. This post is the narrative version.

The 30-Second Version

Three pieces of code, in this order:

  1. Your plugin subscribes to the onApiRegisterRoutes event.
  2. In the handler, you register HTTP routes against a controller class.
  3. The controller extends AbstractApiController and implements one method per route.

That's the whole integration. Authentication, permission checks, JSON parsing, pagination, error responses, RFC 7807 problem details, ETag handling, link generation: all of it is done for you by the base controller. Your code is the business logic and nothing else.

Why Extend the API at All?

A few common reasons:

  • You want to expose plugin data or operations to external tools (build scripts, headless frontends, CI, mobile apps, AI agents via MCP).
  • You're shipping Admin Next UI for your plugin (custom fields, plugin pages, panels, widgets, reports) and the front-end web components need somewhere to talk to. Custom endpoints are how those components fetch and save their data.
  • You want to fire structured events that other plugins (or webhooks) can hook into.
  • You want your plugin to participate in the same authenticated, permission-checked, audit-logged request flow as the rest of the system.

If your plugin only needs to render server-side Twig and read config files, you don't need any of this. The integration is for the cases where the plugin actually has data or actions worth exposing.

Prerequisites

Before any of this works, your plugin needs the basics in place. The API plugin assumes:

  • Grav 2.0 with the API plugin installed and enabled.
  • A composer.json in your plugin with PSR-4 autoloading mapping your namespace to classes/.
  • An autoload() method on your plugin class that requires vendor/autoload.php.

A minimal composer.json:

{
    "autoload": {
        "psr-4": {
            "Grav\\Plugin\\MyPlugin\\": "classes/"
        }
    }
}

And on the plugin class itself:

public function autoload(): \Composer\Autoload\ClassLoader
{
    return require __DIR__ . '/vendor/autoload.php';
}

Run composer dump-autoload whenever you add a new class. If you skip these steps the API plugin can't find your controller, the routes won't resolve, and you'll get a 500 with a "class not found" trail in the logs.

Step 1: Subscribe to onApiRegisterRoutes

In your plugin's main PHP file, add the event subscription:

public static function getSubscribedEvents()
{
    return [
        // ... your existing events ...
        'onApiRegisterRoutes' => ['onApiRegisterRoutes', 0],
    ];
}

The API plugin fires this event exactly once, during router initialization. Every plugin that subscribes gets a chance to add its routes to the shared collection.

Step 2: Register Your Routes

The event hands you an ApiRouteCollector under $event['routes']. It supports the usual HTTP verbs and a group() helper for prefixed groups:

public function onApiRegisterRoutes(Event $event): void
{
    $routes = $event['routes'];
    $controller = \Grav\Plugin\MyPlugin\MyApiController::class;

    $routes->get('/my-resource',         [$controller, 'index']);
    $routes->get('/my-resource/{id}',    [$controller, 'show']);
    $routes->post('/my-resource',        [$controller, 'create']);
    $routes->patch('/my-resource/{id}',  [$controller, 'update']);
    $routes->delete('/my-resource/{id}', [$controller, 'delete']);
}

Or with a group:

$routes->group('/my-plugin', function ($group) use ($controller) {
    $group->get('/items',        [$controller, 'listItems']);
    $group->post('/items',       [$controller, 'createItem']);
    $group->get('/stats',        [$controller, 'stats']);
});

A few things worth knowing about route paths:

  • Paths are appended to the configured API prefix (default /api/v1), so /my-resource is reachable at /api/v1/my-resource.
  • Route parameters use FastRoute syntax: /items/{id}, /items/{id:[0-9]+} for a numeric constraint, and so on.
  • Namespace your top-level paths to avoid collisions. /my-plugin/... is good. /items on its own is asking for a clash.

Step 3: Build the Controller

Create the controller in your plugin's classes/ directory and extend AbstractApiController:

<?php
// classes/MyApiController.php

declare(strict_types=1);

namespace Grav\Plugin\MyPlugin;

use Grav\Plugin\Api\Controllers\AbstractApiController;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class MyApiController extends AbstractApiController
{
    public function index(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $pagination = $this->getPagination($request);

        $items = $this->getItems();
        $total = count($items);
        $paged = array_slice($items, $pagination['offset'], $pagination['limit']);

        return ApiResponse::paginated(
            $paged, $total,
            $pagination['page'], $pagination['per_page'],
            $this->getApiBaseUrl() . '/my-resource'
        );
    }

    public function show(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $id = $this->getRouteParam($request, 'id');
        $item = $this->findItem($id);

        if (!$item) {
            throw new NotFoundException("Item '{$id}' not found.");
        }

        return ApiResponse::create($item);
    }

    public function create(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.write');

        $body = $this->getRequestBody($request);
        $this->requireFields($body, ['name']);

        $item = $this->createItem($body);
        $this->fireEvent('onMyPluginItemCreated', ['item' => $item]);

        return ApiResponse::created(
            $item,
            $this->getApiBaseUrl() . '/my-resource/' . $item['id']
        );
    }

    public function delete(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.write');

        $id = $this->getRouteParam($request, 'id');
        $this->deleteItem($id);

        return ApiResponse::noContent();
    }
}

There's no special boilerplate here. Each method takes a PSR-7 ServerRequestInterface and returns a ResponseInterface. Everything else is helpers on the base class.

What the Base Controller Gives You

AbstractApiController does the heavy lifting. The methods you'll reach for most often:

Method What it does
requirePermission($request, $perm) Verify the authenticated user has the named permission. Throws a 403 if not.
getUser($request) Returns the authenticated Grav user object for the request.
getRequestBody($request) Parses and returns the JSON request body as an associative array.
getRouteParam($request, $name) Pulls a FastRoute named parameter out of the request.
getPagination($request) Reads page and per_page query strings, returns ['page', 'per_page', 'offset', 'limit'].
getSorting($request, $allowedFields) Parses sort and order query strings, whitelisted against the allowed fields.
getFilters($request, $allowedFields) Reads filter query strings, whitelisted against the allowed fields.
requireFields($body, $fields) Validates that the body contains the named required fields. Throws a 422 if not.
validateEtag($request, $hash) Optimistic concurrency check against the If-Match header.
respondWithEtag($data) Returns a response with a generated ETag header.
fireEvent($name, $data) Fires a Grav event, so other plugins can hook into your API actions.
getApiBaseUrl() Returns the absolute base URL of the API, useful for link headers and pagination.

The pattern is "ask for what you need, then write the business logic". You shouldn't be parsing request bodies, building auth headers, or hand-rolling pagination math anywhere in your controllers.

Response Helpers

The ApiResponse class is how you build successful responses. Stick to its static constructors and you get consistent shapes across every endpoint in the system:

use Grav\Plugin\Api\Response\ApiResponse;

ApiResponse::create($data);                           // 200, body: { data: ... }
ApiResponse::created($data, $locationUrl);            // 201 with Location header
ApiResponse::noContent();                             // 204, empty body
ApiResponse::paginated($items, $total, $page, $per, $baseUrl);

The paginated form is worth highlighting. It returns the items wrapped in a data envelope plus a meta block (total, page, per_page, total_pages) and a links block (first, last, prev, next). Every paginated endpoint in the API does it the same way, including Admin Next's own list views, which is how Admin Next can render any of them without special-casing.

Exceptions and Errors

Don't hand-build error responses. Throw a typed exception and the base controller converts it to an RFC 7807 problem-detail JSON response with the right status code:

use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Exceptions\ForbiddenException;
use Grav\Plugin\Api\Exceptions\ConflictException;
use Grav\Plugin\Api\Exceptions\ApiException;

throw new NotFoundException("Item not found.");                 // 404
throw new ValidationException("Invalid input.", $errors);       // 422 with detail array
throw new ForbiddenException("Not allowed.");                   // 403
throw new ConflictException("Resource modified by another user."); // 409
throw new ApiException(503, 'Unavailable', 'Detail here');      // arbitrary status

The point is to keep the controller code straight-line and let the framework worry about the response envelope. If something goes wrong, throw the closest matching exception and move on.

Permissions

Grav's permission system carries over verbatim. The base controller's requirePermission() is a thin wrapper around User::authorize(), so anything you can already express as a permission string works the same way against the API.

Common patterns:

  • Read endpoints check api.system.read (or your own custom api.myplugin.read).
  • Write endpoints check api.system.write (or api.myplugin.write).
  • Plugin-specific scopes are declared in a permissions.yaml file at the root of your plugin. Once registered, they show up in the user permission editor and behave exactly like core permissions.

Pick the right granularity for your plugin. If everything is "anyone with admin access", reuse the core api.system.* permissions. If you have real privilege boundaries (a shop plugin where customer service can refund but only finance can issue store credit), declare your own and check them at the boundary.

Firing Your Own Events

The single most useful thing you can do once your API endpoint is working is to fire a structured event whenever it mutates state:

$this->fireEvent('onMyPluginItemCreated', ['item' => $item]);

That one line does three things:

  1. Other plugins can subscribe to your event and react to it (the same as any other Grav event).
  2. The webhook system listens for onApi* events and dispatches them to every configured webhook URL whose event filter matches, so users can wire your endpoint into external systems without you writing a single line of webhook code.
  3. MCP clients can observe these events through the standard Grav event system, so AI agents get a coherent picture of what's happening on a site.

Use the onApi* prefix on your event names if you want them to flow through the webhook dispatcher. Use a plugin-specific prefix if you only want other plugins to consume them.

Worked Example: License Manager

The free, openly available license-manager plugin is a complete reference. It does full CRUD plus import and export, all backed by an AbstractApiController subclass, and it exposes the result as a sidebar entry inside Admin Next.

The route registration looks like this:

public function onApiRegisterRoutes(Event $event): void
{
    $routes = $event['routes'];
    $controller = LicenseApiController::class;

    $routes->get('/licenses/form-data',      [$controller, 'formData']);
    $routes->patch('/licenses',              [$controller, 'save']);
    $routes->post('/licenses/import',        [$controller, 'import']);
    $routes->get('/licenses/export',         [$controller, 'export']);
    $routes->get('/licenses/products-status',[$controller, 'productsStatus']);
}

That's five endpoints, and between them they power the entire Admin Next "Licenses" page:

Endpoint Used by
GET /licenses/form-data The page's data_endpoint, loads current licenses into the form.
PATCH /licenses The page's save_endpoint, called by the form Save button.
POST /licenses/import The Import toolbar action (file upload).
GET /licenses/export The Export toolbar action (file download).
GET /licenses/products-status A custom blueprint field that lists licensed products and their install state.

Every method in the controller follows the same pattern: check permission, parse the request, run the business logic (delegated to a LicenseManager service that's reused by the rest of the plugin), and return an ApiResponse. The full source is on GitHub at getgrav/grav-plugin-license-manager and it's worth reading top to bottom if this is your first integration.

The pattern of "the same business-logic class is used by both the API controller and any non-API code" is one I'd recommend strongly. Keep the controller thin. The controller's job is HTTP and authentication. The actual work belongs somewhere reusable.

Documenting Your Endpoints

Once your endpoints work, document them. The API plugin and the Learn site both render Helios-formatted API docs, and shipping a small api-docs/ directory inside your plugin gets you a polished, browsable reference page with very little effort.

The convention is one folder per endpoint:

my-plugin/
├── api-docs/
│   ├── chapter.md                 # Overview page
│   ├── 01.list-items/
│   │   └── api-endpoint.md
│   ├── 02.create-item/
│   │   └── api-endpoint.md
│   └── grav-my-plugin-api.postman_collection.json

Each endpoint page uses the api-endpoint template:

---
title: 'List Items'
template: api-endpoint
api:
  method: GET
  path: /my-resource
  description: 'List all items with pagination.'
  parameters:
    - name: page
      type: integer
      required: false
      description: 'Page number (default: 1)'
  response_example: '{"data": [...]}'
  response_codes:
    - code: '200'
      description: 'Success'
    - code: '401'
      description: 'Unauthorized'
---

## Usage Notes

Anything extra you want to call out in prose goes here.

If you also ship a Postman v2.1 collection that uses the standard Grav API environment variables ({{base_url}}, {{api_prefix}}, {{api_key}}, {{grav_environment}}), users can import it and try the endpoints in a single click. The core API plugin's own collection is a good template to crib from.

Testing With curl

For a quick smoke test against a running site, curl is hard to beat. Generate an API key in Admin, then:

# List
curl -s "https://example.com/api/v1/my-resource?page=1&per_page=20" \
  -H "X-API-Key: $GRAV_API_KEY" \
  -H "X-Grav-Environment: localhost" | jq

# Create
curl -s -X POST "https://example.com/api/v1/my-resource" \
  -H "X-API-Key: $GRAV_API_KEY" \
  -H "X-Grav-Environment: localhost" \
  -H "Content-Type: application/json" \
  -d '{"name": "Test Item"}' | jq

# Delete
curl -s -X DELETE "https://example.com/api/v1/my-resource/123" \
  -H "X-API-Key: $GRAV_API_KEY" \
  -H "X-Grav-Environment: localhost" -i

A note on the headers. The API accepts both Authorization: Bearer <token> and X-API-Key: <key> for authentication, but in practice the X-* headers are safer to send from anywhere that runs through FastCGI or PHP-FPM (which silently strips Authorization headers in a few common configurations, including a default MAMP install). When in doubt, send the X-* header.

A Note on Admin Next

If your plugin ships any Admin Next UI (custom fields, pages, panels, widgets, reports), this developer-guide post is the foundation you build on. Every one of those Admin Next extension points uses the same pattern: the UI is a web component, the data and the actions are your API endpoints, and the authentication is the same JWT the rest of Admin Next is already using.

The next few posts in this series go deep on each Admin Next extension point in turn, and they all assume you've got the API integration nailed down first. If you got this post end-to-end working against a real Grav 2.0 site, you've already done the hard part.

FAQ

Do I have to use AbstractApiController?

No, but you really should. You can return any PSR-7 ResponseInterface from a route handler, so a plain closure works in principle. In practice, the base controller is doing real work for you (permission checks, JSON parsing, pagination, error envelopes, ETag handling, link generation) and re-implementing all of that in your own plugin is a waste of time and an excellent way to ship inconsistent responses. Extend the base class.

Can I expose endpoints without authentication?

Yes, but think hard about whether you actually want to. The API's default posture is "authenticated and permission-checked", which is the right default for almost everything. If you have a legitimately public read-only endpoint (a status check, a public catalog), skip the requirePermission() call inside that one method. Don't disable auth at the route level.

What about versioning?

The API plugin handles top-level versioning via the prefix (/api/v1). For plugin-specific endpoints, if you ever need to break your own shape, the simplest path is to register a parallel route and deprecate the old one with a Deprecation header. We'll cover the deprecation conventions in a follow-up post once we've shipped a real example of it in the core plugins.

Can my plugin call the API from PHP?

Yes, and there are two ways. The lighter option is to just call your service classes directly, the same way the controller does. The heavier option (when you want the full HTTP semantics, including auth and permission checks) is to instantiate a PSR-7 request and dispatch it through the API plugin's internal dispatcher. For the cases where you want the former, structure your plugin so the work is in a service class that both the controller and the rest of your plugin can call.

Where do I add my plugin's permissions?

Drop a permissions.yaml at the root of your plugin. The API plugin's own permissions.yaml is a worked example: it declares the api.* namespace and is loaded automatically at boot. Your permissions.yaml follows the same shape, and your declared permissions show up in the user permission editor alongside the core ones.

Quick Links and Help

If you take one thing away from this post, it's that the API plugin has already done the hard parts. Your job is the business logic. The controller is thin, the response shapes are consistent, the auth and permission story is the same as the rest of Grav, and the documentation and webhook integration come along for the ride.

— Andy


Grav Premium
Turbo-charge your Grav site - from the creators of Grav