Extending WP_REST_Controller is the correct way to build a WordPress REST API endpoint — it provides CRUD scaffolding, schema-driven argument validation, and the permission callback pattern used throughout WordPress core.
Problem: WordPress's built-in WP_REST_Controller base class is not well-documented — developers typically copy-paste endpoint registration code without a clear structure for schema definition, permission callbacks, and response formatting.
Solution: Extend WP_REST_Controller and override register_routes(), get_item_schema(), and get_collection_params(). Use $this->get_endpoint_args_for_item_schema() to auto-generate argument validation from your JSON schema, and return WP_REST_Response objects from all callbacks for consistent headers and status codes.
The example below builds a complete controller class for a "Team Member" custom post type, with register_routes(), collection/single-item handling, prepare_item_for_response(), and get_item_schema().
namespace = 'myplugin/v1';
$this->rest_base = 'team';
}
public function register_routes(): void {
register_rest_route( $this->namespace, '/' . $this->rest_base, [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
'args' => $this->get_collection_params(),
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_item' ],
'permission_callback' => [ $this, 'create_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
],
'schema' => [ $this, 'get_public_item_schema' ],
] );
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_item' ],
'permission_callback' => [ $this, 'get_item_permissions_check' ],
'args' => [ 'context' => $this->get_context_param( [ 'default' => 'view' ] ) ],
],
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'update_item' ],
'permission_callback' => [ $this, 'update_item_permissions_check' ],
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::EDITABLE ),
],
'schema' => [ $this, 'get_public_item_schema' ],
] );
}
public function get_items_permissions_check( $request ): bool {
return true; // public read access
}
public function create_item_permissions_check( $request ): bool|WP_Error {
return current_user_can( 'edit_posts' )
? true
: new WP_Error( 'rest_forbidden', 'Insufficient permissions.', [ 'status' => 403 ] );
}
public function get_items( $request ): WP_REST_Response|WP_Error {
$query = new WP_Query( [
'post_type' => 'team_member',
'posts_per_page' => (int) $request->get_param( 'per_page' ),
'paged' => (int) $request->get_param( 'page' ),
'post_status' => 'publish',
] );
$items = array_map(
fn( $post ) => $this->prepare_response_for_collection(
$this->prepare_item_for_response( $post, $request )
),
$query->posts
);
$response = rest_ensure_response( $items );
$response->header( 'X-WP-Total', (string) $query->found_posts );
$response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );
return $response;
}
public function prepare_item_for_response( $post, $request ): WP_REST_Response {
return rest_ensure_response( [
'id' => $post->ID,
'name' => $post->post_title,
'bio' => wp_strip_all_tags( $post->post_content ),
'role' => get_post_meta( $post->ID, '_team_role', true ),
'avatar_url' => get_the_post_thumbnail_url( $post->ID, 'medium' ) ?: null,
'_links' => [
'self' => [ [ 'href' => rest_url( "{$this->namespace}/{$this->rest_base}/{$post->ID}" ) ] ],
'collection' => [ [ 'href' => rest_url( "{$this->namespace}/{$this->rest_base}" ) ] ],
],
] );
}
public function get_item_schema(): array {
if ( $this->schema ) return $this->schema;
$this->schema = [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'team-member',
'type' => 'object',
'properties' => [
'id' => [ 'type' => 'integer', 'readonly' => true ],
'name' => [ 'type' => 'string', 'required' => true ],
'bio' => [ 'type' => 'string' ],
'role' => [ 'type' => 'string' ],
'avatar_url' => [ 'type' => ['string', 'null'], 'format' => 'uri' ],
],
];
return $this->schema;
}
}
add_action( 'rest_api_init', function() {
( new My_REST_Team_Controller() )->register_routes();
} );
NOTE: Calling $this->get_endpoint_args_for_item_schema() auto-generates the args array from your schema, so WordPress validates type, format, and required fields before your callback runs. Test with curl https://example.com/wp-json/myplugin/v1/team | jq . or wp rest team list --user=admin.