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((… 
