DEV Community

Cover image for Stop piling up register_rest_route(): build WordPress REST APIs with PHP 8 attributes
Dimitri BOUTEILLE
Dimitri BOUTEILLE

Posted on

Stop piling up register_rest_route(): build WordPress REST APIs with PHP 8 attributes

Your WordPress REST API deserves controllers, not a 600-line functions.php. Here is how to get there with PHP 8 attributes.

The WordPress REST API is one of the best things that happened to WordPress. It powers the block editor, headless front-ends in Next.js or Nuxt, mobile apps and countless integrations.

Adding your own endpoints, though, has not changed much since 2016. You call register_rest_route(), pass an array of options, write a callback that reads $request->get_param() by hand, and remember to add a permission_callback. For two endpoints, that is fine. For thirty, it becomes a long file of nested arrays that nobody wants to touch.

In this article, we will build a small API for documents twice: first the native way, then with PHP 8 attributes using wp-module-rest-api, an open-source package I maintain. By the end, each endpoint will be a typed method in a plain PHP class.

You will need PHP 8.1+, Composer, and a WordPress install that loads Composer's autoloader. A Bedrock project is perfect, but a mu-plugin with its own vendor/ works too.

The native way

Our API needs two endpoints: read a document and delete it. Reading is public; deleting is reserved to administrators. With plain WordPress, it looks like this:

add_action('rest_api_init', function () {
    register_rest_route('app/v1', '/documents/(?P<id>\d+)', [
        [
            'methods' => 'GET',
            'callback' => function (\WP_REST_Request $request) {
                $document = get_post((int) $request->get_param('id'));
                if (!$document instanceof \WP_Post) {
                    return new \WP_Error('not-found', 'Document not found.', ['status' => 404]);
                }

                return new \WP_REST_Response([
                    'id' => $document->ID,
                    'title' => $document->post_title,
                ]);
            },
            'permission_callback' => '__return_true',
        ],
        [
            'methods' => 'DELETE',
            'callback' => function (\WP_REST_Request $request) {
                $deleted = wp_delete_post((int) $request->get_param('id'), true);

                return new \WP_REST_Response(['deleted' => (bool) $deleted]);
            },
            'permission_callback' => fn () => current_user_can('administrator'),
        ],
    ]);
});
Enter fullscreen mode Exit fullscreen mode

It works, but look at what it costs:

  • Everything is an array. Your IDE cannot autocomplete the keys, and a typo in permission_callback fails silently.
  • Parameters are untyped. Every callback starts with $request->get_param() and a manual cast.
  • Security is opt-in. Forget permission_callback and your endpoint is public. WordPress only logs a notice.
  • Errors are ad hoc. Each callback builds its own WP_Error, with its own codes and status.
  • It does not scale. Thirty endpoints means thirty of these blocks, usually in one file.

Of course, this code can be refactored: move the callbacks into classes, share the permission checks in functions, write a small helper around register_rest_route(). It helps, but the core issue remains: every endpoint is still a configuration array to write and maintain next to the code that does the work, and the whole thing stays verbose.

Setting up the attribute-based approach

Install the package with Composer:

composer require dbout/wp-module-rest-api
Enter fullscreen mode Exit fullscreen mode

Routes are discovered from a PSR-4 namespace, so make sure your composer.json autoloads your code:

{
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then register the loader once, early in the WordPress boot. A mu-plugin is a good place:

<?php
// wp-content/mu-plugins/api.php

require __DIR__ . '/../../vendor/autoload.php';

use Dbout\WpRestApi\NamespaceRouteLoader;

(new NamespaceRouteLoader('App\\Routes\\'))->register();
Enter fullscreen mode Exit fullscreen mode

Adjust the path to vendor/autoload.php to your project layout. Every class under App\Routes\ is now scanned for routes, and register() hooks into rest_api_init for you.

Your first controller

Create src/Routes/DocumentController.php:

<?php

namespace App\Routes;

use Dbout\WpRestApi\Attributes\Action;
use Dbout\WpRestApi\Attributes\Route;
use Dbout\WpRestApi\Enums\Method;

#[Route('app/v1', '/documents/(?P<id>\d+)')]
class DocumentController
{
    #[Action(Method::GET)]
    public function get(int $id): \WP_REST_Response
    {
        $document = get_post($id);

        return new \WP_REST_Response([
            'id' => $document?->ID,
            'title' => $document?->post_title,
        ]);
    }

    #[Action(Method::DELETE)]
    public function delete(int $id): \WP_REST_Response
    {
        return new \WP_REST_Response([
            'deleted' => (bool) wp_delete_post($id, true),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

That is the whole registration. GET and DELETE /wp-json/app/v1/documents/42 now hit these two methods.

A few things happen behind the scenes:

  • #[Route] holds the namespace and the path, exactly as register_rest_route() expects them.
  • #[Action] maps a method to one or several HTTP methods: #[Action([Method::POST, Method::PUT])] works too.
  • Arguments are injected by name. The id captured by the URL is passed to $id and cast to int. The same goes for query string and body parameters.
  • Need the raw request for headers or a JSON body? Add a \WP_REST_Request $request argument and you get it.

No more get_param(), no more manual casting, and your IDE understands every line. We are not done, though: right now, anyone can delete a document.

Locking it down with permissions

Permissions are declared with the same attributes. Set permissionCallback on #[Route] to protect every action of the class, or on #[Action] to protect a single one:

use Dbout\WpRestApi\Permissions\IsAdministrator;

#[Action(Method::DELETE, permissionCallback: IsAdministrator::class)]
public function delete(int $id): \WP_REST_Response
{
    // Only administrators get here
}
Enter fullscreen mode Exit fullscreen mode

The package ships with IsAdministrator, IsEditor, IsAuthor, IsContributor and IsSubscriber. For anything more specific, implement PermissionInterface. Here is a webhook endpoint that only accepts requests signed with a shared secret:

use Dbout\WpRestApi\Permissions\PermissionInterface;

class SignaturePermission implements PermissionInterface
{
    public function allow(\WP_REST_Request $request): bool|\WP_Error
    {
        $signature = (string) $request->get_header('signature');
        $expected = hash_hmac('sha256', $request->get_body(), WEBHOOK_SECRET);

        return hash_equals($expected, $signature);
    }
}
Enter fullscreen mode Exit fullscreen mode
#[Route('app/v1', '/webhooks/payment', permissionCallback: SignaturePermission::class)]
class PaymentWebhookController
{
    #[Action(Method::POST)]
    public function handle(\WP_REST_Request $request): \WP_REST_Response
    {
        // The signature is valid
    }
}
Enter fullscreen mode Exit fullscreen mode

The permission lives in its own class: you can reuse it on any route and unit test it on its own. And because it is visible right on top of the class, a code review spots a missing check immediately.

Validating input with #[Param]

Let's add a list endpoint with filters. The #[Param] attribute turns method arguments into the args schema WordPress already knows how to validate:

enum Status: string
{
    case Draft = 'draft';
    case Published = 'published';
}

#[Route('app/v1', '/documents')]
class DocumentListController
{
    #[Action(Method::GET)]
    public function list(
        #[Param(required: true, description: 'Category identifier')]
        int $category,
        #[Param]
        Status $status = Status::Published,
        #[Param(name: 'per_page')]
        int $perPage = 10,
    ): \WP_REST_Response {
        // $status is a Status instance, $perPage comes from ?per_page=
    }
}
Enter fullscreen mode Exit fullscreen mode

From these few lines, the package builds the schema for you:

  • The JSON type comes from the PHP type: int becomes integer, bool becomes boolean.
  • A sanitizer is picked from the PHP type too, such as sanitize_text_field for strings.
  • A backed enum restricts the accepted values to its cases, so ?status=archived is rejected.
  • A missing category returns a 400 from WordPress, before your code runs.

You still write plain PHP, but you get the validation of a hand-written schema. The descriptions even show up in the /wp-json/ index, which helps whoever consumes your API.

Errors as exceptions

Our get() method still returns an empty document when the ID does not exist. Instead of building a WP_Error by hand, throw an exception:

use Dbout\WpRestApi\Exceptions\NotFoundException;

#[Action(Method::GET)]
public function get(int $id): \WP_REST_Response
{
    $document = get_post($id);
    if (!$document instanceof \WP_Post) {
        throw new NotFoundException('Document');
    }

    return new \WP_REST_Response([
        'id' => $document->ID,
        'title' => $document->post_title,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

The client receives a 404 with a predictable body:

{
  "error": {
    "code": "not-found",
    "message": "Document not found.",
    "data": []
  }
}
Enter fullscreen mode Exit fullscreen mode

For business errors, RouteException lets you choose the code, the HTTP status and extra data:

throw new RouteException(
    message: 'This document is already published.',
    errorCode: 'already-published',
    httpStatusCode: 409,
);
Enter fullscreen mode Exit fullscreen mode

And what about bugs? Any other exception returns a 500 with a generic message, so a database error never leaks into a public response. During development, a debug option shows the real message and the stack trace.

If your front-end team prefers a standard, the package also ships an RFC 7807 formatter that returns application/problem+json responses.

Ready for production

Scanning classes on every request has a cost once you have dozens of controllers. Plug in any PSR-6 cache pool and the routes are discovered once:

use Dbout\WpRestApi\NamespaceRouteLoader;
use Dbout\WpRestApi\RouteLoaderOptions;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;

$options = new RouteLoaderOptions(
    cache: new FilesystemAdapter(directory: WP_CONTENT_DIR . '/cache/rest-api'),
    debug: wp_get_environment_type() === 'development',
);

(new NamespaceRouteLoader('App\\Routes\\', $options))->register();
Enter fullscreen mode Exit fullscreen mode

Two things to keep in mind:

  • Clear the cache on each deployment, otherwise new routes are ignored.
  • Keep debug off in production. Stack traces are only added when both debug and WP_DEBUG are true, as a safety net.

Wrapping up

We started with nested arrays and closures, and ended with plain PHP classes:

  • one controller per resource, discovered automatically through PSR-4;
  • typed arguments injected by name, validated with #[Param];
  • permissions declared next to the code they protect;
  • errors thrown as exceptions, returned in a consistent format.

Under the hood, nothing magical happens: every route still goes through register_rest_route(). You keep the full WordPress REST API, including its index and its authentication, with a structure that holds up as your API grows.

The package is open source and MIT-licensed. The full documentation covers every option. If it saves you a few hundred lines, a star on GitHub helps other developers find it, and issues and pull requests are welcome.

composer require dbout/wp-module-rest-api
Enter fullscreen mode Exit fullscreen mode

GitHub logo dimitriBouteille / wp-module-rest-api

🚀 Quickly add routes to the WordPress REST API.

WordPress Rest API

GitHub Release tests Packagist Downloads

Declare your WordPress REST API routes with PHP 8 attributes, in plain classes, instead of piling up register_rest_route() calls.

#[Route('app/v1', '/documents/(?P<id>\d+)')]
class DocumentController
{
    #[Action(Method::GET)]
    public function get(int $id): \WP_REST_Response
    {
        // $id comes straight from the URL, already cast to int
    }
}
Enter fullscreen mode Exit fullscreen mode

Why?

With native WordPress, every endpoint means a register_rest_route() call, a callback that reads $request->get_param() by hand, and error handling to write yourself. Once you have more than a few routes, it gets hard to keep organized.

Before, with native WordPress:

add_action('rest_api_init', function () {
    register_rest_route('app/v1', '/documents/(?P<id>\d+)', [
        [
            'methods' => 'GET'
            'callback' => function (\WP_REST_Request $request) {
                $document = get_post((
…
Enter fullscreen mode Exit fullscreen mode

Top comments (0)