REST API Routing

This document covers the WordPress REST API routing features in detail.

Overview

The RestRouter provides a Laravel-like fluent API for registering WordPress REST API endpoints. Routes are registered via register_rest_route() on the rest_api_init hook.

Basic Usage

Create routes/api.php in your plugin:

<?php

use WPZylos\Framework\Routing\RestRouter;
use App\Controllers\Api\UserController;

return function (RestRouter $rest) {
    $rest->get('/users', [UserController::class, 'index']);
    $rest->post('/users', [UserController::class, 'store']);
    $rest->get('/users/{id}', [UserController::class, 'show']);
    $rest->put('/users/{id}', [UserController::class, 'update']);
    $rest->delete('/users/{id}', [UserController::class, 'destroy']);
};

Endpoint URLs

Routes are namespaced under /wp-json/{plugin-slug}/{version}/:

Route DefinitionEndpoint URL
$rest->get('/users')GET /wp-json/my-plugin/v1/users
$rest->post('/users')POST /wp-json/my-plugin/v1/users
$rest->get('/users/{id}')GET /wp-json/my-plugin/v1/users/(?P<id>[^/]+)

HTTP Methods

$rest->get('/resource', $handler);     // GET request
$rest->post('/resource', $handler);    // POST request
$rest->put('/resource/{id}', $handler);    // PUT request
$rest->patch('/resource/{id}', $handler);  // PATCH request
$rest->delete('/resource/{id}', $handler); // DELETE request

// Multiple methods
$rest->match(['GET', 'POST'], '/resource', $handler);

Route Parameters

Basic Parameters

$rest->get('/posts/{id}', function (\WP_REST_Request $request) {
    $id = $request->get_param('id');
    return ['post_id' => $id];
});

Parameter Constraints

$rest->get('/posts/{id}', [PostController::class, 'show'])
    ->where('id', '[0-9]+');  // Only numeric IDs

$rest->get('/posts/{slug}', [PostController::class, 'showBySlug'])
    ->where('slug', '[a-z0-9-]+');  // Slug format

Permissions

Capability-Based

// Require 'edit_posts' capability
$rest->post('/posts', [PostController::class, 'store'])
    ->permission('edit_posts');

// Require 'manage_options' (admin only)
$rest->get('/settings', [SettingsController::class, 'index'])
    ->permission('manage_options');

Custom Callback

$rest->delete('/posts/{id}', [PostController::class, 'destroy'])
    ->setPermissionCallback(function (\WP_REST_Request $request) {
        $post_id = $request->get_param('id');
        return current_user_can('delete_post', $post_id);
    });

Public Endpoints

By default, routes use __return_true as permission callback (public access):

// Public endpoint - no authentication required
$rest->get('/products', [ProductController::class, 'index']);

Argument Schema

Define and validate request arguments:

$rest->post('/users', [UserController::class, 'store'])
    ->args([
        'email' => [
            'required' => true,
            'type' => 'string',
            'format' => 'email',
            'description' => 'User email address',
        ],
        'name' => [
            'required' => true,
            'type' => 'string',
            'minLength' => 2,
            'maxLength' => 100,
            'sanitize_callback' => 'sanitize_text_field',
        ],
        'role' => [
            'type' => 'string',
            'enum' => ['subscriber', 'contributor', 'author'],
            'default' => 'subscriber',
        ],
    ]);

Validation Types

TypeDescription
stringString value
integerInteger value
numberNumeric value (int or float)
booleanBoolean value
arrayArray value
objectObject value

Common Validators

'args' => [
    'email' => ['format' => 'email'],
    'url' => ['format' => 'uri'],
    'ip' => ['format' => 'ip'],
    'date' => ['format' => 'date-time'],
]

Resource Routes

Quickly scaffold CRUD endpoints:

// Creates all CRUD routes
$rest->resource('posts', PostController::class);

// Equivalent to:
$rest->get('/posts', [PostController::class, 'index']);
$rest->post('/posts', [PostController::class, 'store']);
$rest->get('/posts/{id}', [PostController::class, 'show']);
$rest->put('/posts/{id}', [PostController::class, 'update']);
$rest->delete('/posts/{id}', [PostController::class, 'destroy']);

Limiting Actions

// Only index and show
$rest->resource('posts', PostController::class, ['index', 'show']);

// Exclude destroy
$rest->resource('posts', PostController::class, ['index', 'store', 'show', 'update']);

Route Groups

Prefix Groups

$rest->group(['prefix' => '/admin'], function ($rest) {
    $rest->get('/users', [AdminController::class, 'users']);
    $rest->get('/stats', [AdminController::class, 'stats']);
});

// Results in:
// GET /wp-json/my-plugin/v1/admin/users
// GET /wp-json/my-plugin/v1/admin/stats

Middleware Groups

$rest->group(['middleware' => [AuthMiddleware::class]], function ($rest) {
    $rest->get('/profile', [ProfileController::class, 'show']);
    $rest->put('/profile', [ProfileController::class, 'update']);
});

Nested Groups

$rest->group(['prefix' => '/admin'], function ($rest) {
    $rest->group(['prefix' => '/users'], function ($rest) {
        $rest->get('/', [UserController::class, 'index']);
        $rest->get('/{id}', [UserController::class, 'show']);
    });
});

// Results in: /wp-json/my-plugin/v1/admin/users/

Controller Implementation

Basic Controller

<?php

namespace App\Controllers\Api;

use WP_REST_Request;
use WP_REST_Response;

class UserController
{
    public function index(WP_REST_Request $request): array
    {
        $page = $request->get_param('page') ?? 1;
        $per_page = $request->get_param('per_page') ?? 10;

        $users = get_users([
            'number' => $per_page,
            'paged' => $page,
        ]);

        return [
            'users' => array_map(fn($u) => [
                'id' => $u->ID,
                'name' => $u->display_name,
                'email' => $u->user_email,
            ], $users),
            'total' => count_users()['total_users'],
        ];
    }

    public function show(WP_REST_Request $request): array|WP_REST_Response
    {
        $id = (int) $request->get_param('id');
        $user = get_user_by('ID', $id);

        if (!$user) {
            return new WP_REST_Response(['error' => 'User not found'], 404);
        }

        return [
            'id' => $user->ID,
            'name' => $user->display_name,
            'email' => $user->user_email,
        ];
    }

    public function store(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = wp_create_user(
            $request->get_param('username'),
            $request->get_param('password'),
            $request->get_param('email')
        );

        if (is_wp_error($user_id)) {
            return new WP_REST_Response(['error' => $user_id->get_error_message()], 400);
        }

        return new WP_REST_Response(['id' => $user_id], 201);
    }
}

Error Responses

use WP_Error;
use WP_REST_Response;

public function show(WP_REST_Request $request): array|WP_Error
{
    $id = $request->get_param('id');
    $post = get_post($id);

    if (!$post) {
        return new WP_Error(
            'not_found',
            'Post not found',
            ['status' => 404]
        );
    }

    return ['post' => $post];
}

Testing Endpoints

cURL

# GET request
curl https://example.com/wp-json/my-plugin/v1/users

# POST with authentication
curl -X POST https://example.com/wp-json/my-plugin/v1/users \
  -H "Content-Type: application/json" \
  -H "X-WP-Nonce: YOUR_NONCE" \
  -d '{"email": "[email protected]", "name": "John"}'

JavaScript (fetch)

fetch("/wp-json/my-plugin/v1/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-WP-Nonce": wpApiSettings.nonce,
  },
  body: JSON.stringify({
    email: "[email protected]",
    name: "John Doe",
  }),
})
  .then((response) => response.json())
  .then((data) => console.log(data));

Discovery

Your endpoints appear in the WordPress REST API discovery:

GET /wp-json/

Look for your namespace in the response:

{
  "namespaces": ["wp/v2", "my-plugin/v1"]
}