When a plugin exposes a REST API, choosing and structuring the namespace correctly is critical for long-term maintainability. WordPress REST routes are identified by a namespace (e.g., my-plugin/v1) and a route pattern (e.g., /items/(?P<id>[\d]+)). The namespace includes a version slug — conventionally v1, v2 — that allows breaking changes to be introduced in a new namespace without removing the old one, giving API consumers time to migrate. A versioned namespace strategy means that my-plugin/v1/items and my-plugin/v2/items can coexist: v1 returns the old response shape, v2 returns the new one. WordPress core itself uses this pattern: wp/v2 is the current namespace, and if a v3 ever ships, both would coexist. Implementing clean versioning from day one — even for internal APIs — avoids the brittle, non-versioned API pattern that breaks consuming code on every plugin update.
Problem: A plugin's first REST API version (my-plugin/v1) returns a flat array of products. A new feature requires a breaking change: the response must now be paginated with metadata ({'products': [...], 'total': N, 'pages': N}). The v2 API needs to coexist with v1 until all consuming apps have migrated, and both versions need proper authentication and schema validation.
Solution: Register both namespace versions with register_rest_route(), keeping v1 unchanged. Add v2 routes with the new response shape. Share the permission callback and core business logic between versions.
<?php
// ── Shared constants ───────────────────────────────────────────────────
const MY_PLUGIN_REST_NS_V1 = 'my-plugin/v1';
const MY_PLUGIN_REST_NS_V2 = 'my-plugin/v2';
// ── Register both API versions ────────────────────────────────────────
add_action( 'rest_api_init', function () {
// ─ v1: flat array response (DEPRECATED — keep for backwards compat)
register_rest_route( MY_PLUGIN_REST_NS_V1, '/products', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'my_plugin_get_products_v1',
'permission_callback' => 'my_plugin_api_permission',
'args' => [
'per_page' => [
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
'sanitize_callback' => 'absint',
],
],
] );
// ─ v2: paginated response with metadata
register_rest_route( MY_PLUGIN_REST_NS_V2, '/products', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'my_plugin_get_products_v2',
'permission_callback' => 'my_plugin_api_permission',
'args' => [
'per_page' => [
'type' => 'integer',
'default' => 10,
'minimum' => 1,
'maximum' => 100,
],
'page' => [
'type' => 'integer',
'default' => 1,
'minimum' => 1,
],
],
'schema' => 'my_plugin_get_products_schema_v2',
] );
// ─ v2: single product endpoint (new in v2)
register_rest_route( MY_PLUGIN_REST_NS_V2, '/products/(?P<id>[\d]+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'my_plugin_get_product_v2',
'permission_callback' => 'my_plugin_api_permission',
'args' => [
'id' => [
'type' => 'integer',
'validate_callback' => fn( $val ) => $val > 0,
],
],
] );
} );
// ── Shared permission callback ─────────────────────────────────────────
function my_plugin_api_permission(): bool {
return current_user_can( 'edit_posts' ) || is_user_logged_in();
}
// ── v1 callback — flat array ──────────────────────────────────────────
function my_plugin_get_products_v1( WP_REST_Request $req ): WP_REST_Response {
$items = my_plugin_query_products( [
'per_page' => $req->get_param( 'per_page' ),
'page' => 1,
] );
// Deprecated: add header to signal migration
$response = new WP_REST_Response( $items['products'] );
$response->header( 'X-API-Deprecated', 'true' );
$response->header( 'X-API-Migrate-To', rest_url( MY_PLUGIN_REST_NS_V2 . '/products' ) );
return $response;
}
// ── v2 callback — paginated with meta ────────────────────────────────
function my_plugin_get_products_v2( WP_REST_Request $req ): WP_REST_Response {
$result = my_plugin_query_products( [
'per_page' => $req->get_param( 'per_page' ),
'page' => $req->get_param( 'page' ),
] );
$response = new WP_REST_Response( $result );
$response->header( 'X-WP-Total', $result['total'] );
$response->header( 'X-WP-TotalPages', $result['pages'] );
return $response;
}
// ── Shared business logic ─────────────────────────────────────────────
function my_plugin_query_products( array $params ): array {
$per_page = absint( $params['per_page'] );
$page = absint( $params['page'] );
$offset = ( $page - 1 ) * $per_page;
global $wpdb;
$table = $wpdb->prefix . 'my_products';
$items = $wpdb->get_results( $wpdb->prepare(
"SELECT id, name, price FROM {$table} ORDER BY id LIMIT %d OFFSET %d",
$per_page, $offset
), ARRAY_A );
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" );
return [
'products' => $items,
'total' => $total,
'pages' => max( 1, (int) ceil( $total / $per_page ) ),
];
}
// ── v2 JSON schema for validation and documentation ───────────────────
function my_plugin_get_products_schema_v2(): array {
return [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'products',
'type' => 'object',
'properties' => [
'products' => [ 'type' => 'array' ],
'total' => [ 'type' => 'integer' ],
'pages' => [ 'type' => 'integer' ],
],
];
}
NOTE: WordPress discovers all registered REST namespaces automatically — hitting /wp-json/ returns a list of all namespaces and their routes. Both my-plugin/v1 and my-plugin/v2 will appear there. Adding a deprecation header (X-API-Deprecated: true) with a migration URL in v1 responses is the recommended way to signal to API consumers that they should upgrade. Set a deprecation timeline (e.g., "v1 will be removed in plugin version 3.0") in your documentation. For plugin deactivation cleanup, only remove the route registrations — WordPress routes are registered on each request, not stored in the database, so there is nothing to delete on uninstall.