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'),
],
]);
});
It works, but look at what it costs:
-
Everything is an array. Your IDE cannot autocomplete the keys, and a typo in
permission_callbackfails silently. -
Parameters are untyped. Every callback starts with
$request->get_param()and a manual cast. -
Security is opt-in. Forget
permission_callbackand 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
Routes are discovered from a PSR-4 namespace, so make sure your composer.json autoloads your code:
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
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();
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),
]);
}
}
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 asregister_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
idcaptured by the URL is passed to$idand cast toint. The same goes for query string and body parameters. - Need the raw request for headers or a JSON body? Add a
\WP_REST_Request $requestargument 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
}
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);
}
}
#[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
}
}
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=
}
}
From these few lines, the package builds the schema for you:
- The JSON type comes from the PHP type:
intbecomesinteger,boolbecomesboolean. - A sanitizer is picked from the PHP type too, such as
sanitize_text_fieldfor strings. - A backed enum restricts the accepted values to its cases, so
?status=archivedis rejected. - A missing
categoryreturns a400from 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,
]);
}
The client receives a 404 with a predictable body:
{
"error": {
"code": "not-found",
"message": "Document not found.",
"data": []
}
}
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,
);
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();
Two things to keep in mind:
- Clear the cache on each deployment, otherwise new routes are ignored.
- Keep
debugoff in production. Stack traces are only added when bothdebugandWP_DEBUGare 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
dimitriBouteille
/
wp-module-rest-api
🚀 Quickly add routes to the WordPress REST API.
WordPress Rest API
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
}
}
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((…
Top comments (0)