Building WordPress REST API Controllers with WP_REST_Controller

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.

Leave Comment

Your email address will not be published. Required fields are marked *