Une extension de gestion de flotte de véhicules pour des entreprises de location expose, au fil des versions, de plus en plus de ressources via l’API REST de WordPress : véhicules, réservations, conducteurs, incidents. Chaque nouvelle ressource a été ajoutée par un appel supplémentaire à register_rest_route() avec une fonction anonyme en callback, jusqu’à atteindre un fichier de plus de huit cents lignes où il devient difficile de retrouver la logique propre à chaque ressource, tant tout est mélangé dans un seul espace de noms de fichier.
La refonte adoptée s’appuie sur WP_REST_Controller, la classe abstraite que WordPress fournit lui-même en interne pour structurer ses propres points de terminaison REST, comme ceux des articles ou des utilisateurs. Cette classe n’apporte aucune magie particulière : elle impose surtout une convention de nommage de méthodes qui rend le code de chaque ressource immédiatement reconnaissable d’un développeur à l’autre.
Le squelette d’un contrôleur de ressource

Un contrôleur de ressource commence par un constructeur qui fixe l’espace de noms et la base de la route, puis une méthode register_routes() qui enregistre les deux familles de routes d’une ressource : la collection (/vehicules) et l’élément (/vehicules/12). Voici le squelette, limité à la lecture et à la création :
class Flotte_Vehicules_Controller extends WP_REST_Controller {
public function __construct() {
$this->namespace = 'flotte/v1';
$this->rest_base = 'vehicules';
}
public function register_routes() {
register_rest_route( $this->namespace, '/' . $this->rest_base, array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => $this->get_collection_params(),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
'permission_callback' => array( $this, 'create_item_permissions_check' ),
'args' => $this->get_endpoint_args_for_item_schema( WP_REST_Server::CREATABLE ),
),
'schema' => array( $this, 'get_public_item_schema' ),
) );
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', array(
'args' => array(
'id' => array(
'description' => __( 'Identifiant du véhicule.', 'flotte' ),
'type' => 'integer',
),
),
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
'args' => array(
'context' => $this->get_context_param( array( 'default' => 'view' ) ),
),
),
'schema' => array( $this, 'get_public_item_schema' ),
) );
}
}
add_action( 'rest_api_init', function () {
$controleur = new Flotte_Vehicules_Controller();
$controleur->register_routes();
} );
Trois éléments de ce squelette font le travail de structuration. Le tableau d’arguments de la collection provient de get_collection_params(), qui fournit déjà context, page, per_page et search, avec leur schéma. Les arguments de création sont déduits du schéma de la ressource par get_endpoint_args_for_item_schema() : on ne les écrit donc pas deux fois. Enfin la clé schema permet à WordPress de publier le schéma de la ressource, y compris dans la réponse à une requête OPTIONS.
Les méthodes que l’on surcharge
La classe de base définit une convention de noms, et chaque méthode a un rôle précis. On ne surcharge que celles dont la ressource a besoin : une ressource en lecture seule n’implémente ni create_item() ni delete_item(), et la classe de base renvoie dans ce cas une erreur « non implémenté » plutôt que de laisser un trou.
get_items_permissions_check(),get_item_permissions_check(),create_item_permissions_check(): une méthode de permission par action, qui retournetrueou unWP_Error.get_items(),get_item(),create_item(),update_item(),delete_item(): la logique de chaque action.prepare_item_for_response(): transforme l’objet interne (ici un article) en tableau conforme au schéma.get_item_schema(): décrit la ressource au format JSON Schema.
Permissions, collection et préparation de la réponse
Les méthodes suivantes se placent à l’intérieur de la classe. Chaque action a sa propre vérification. L’intérêt est de pouvoir les tester séparément et de lire en quelques lignes qui a le droit de faire quoi :
public function get_items_permissions_check( $request ) {
if ( ! current_user_can( 'gerer_flotte' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Vous ne pouvez pas consulter la flotte.', 'flotte' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
public function get_items( $request ) {
$requete = new WP_Query( array(
'post_type' => 'vehicule',
'post_status' => 'publish',
'posts_per_page' => $request['per_page'],
'paged' => $request['page'],
's' => $request['search'],
) );
$vehicules = array();
foreach ( $requete->posts as $post ) {
$donnees = $this->prepare_item_for_response( $post, $request );
$vehicules[] = $this->prepare_response_for_collection( $donnees );
}
$reponse = rest_ensure_response( $vehicules );
$reponse->header( 'X-WP-Total', (int) $requete->found_posts );
$reponse->header( 'X-WP-TotalPages', (int) $requete->max_num_pages );
return $reponse;
}
public function prepare_item_for_response( $post, $request ) {
$champs = $this->get_fields_for_response( $request );
$donnees = array();
if ( rest_is_field_included( 'id', $champs ) ) {
$donnees['id'] = (int) $post->ID;
}
if ( rest_is_field_included( 'immatriculation', $champs ) ) {
$donnees['immatriculation'] = (string) get_post_meta( $post->ID, '_immatriculation', true );
}
if ( rest_is_field_included( 'kilometrage', $champs ) ) {
$donnees['kilometrage'] = (int) get_post_meta( $post->ID, '_kilometrage', true );
}
$contexte = ! empty( $request['context'] ) ? $request['context'] : 'view';
$donnees = $this->add_additional_fields_to_object( $donnees, $request );
$donnees = $this->filter_response_by_context( $donnees, $contexte );
return rest_ensure_response( $donnees );
}
La capacité gerer_flotte est ici une capacité métier, à ajouter aux rôles concernés. Le code HTTP renvoyé par rest_authorization_required_code() est 401 pour un visiteur non connecté et 403 pour un utilisateur connecté mais non autorisé, ce qui permet au front de distinguer « connectez-vous » de « vous n’avez pas le droit ». Les fonctions get_fields_for_response() et rest_is_field_included(), disponibles depuis WordPress 5.3, font respecter le paramètre _fields : le client peut ne demander que l’immatriculation, et la réponse ne calcule que ce champ.
Le schéma, source de vérité de la ressource
Cette méthode se place elle aussi dans la classe. Le schéma est la pièce la plus rentable du contrôleur : il sert à la fois à documenter la ressource, à valider les données de création et à filtrer les champs selon le contexte.
public function get_item_schema() {
if ( $this->schema ) {
return $this->add_additional_fields_schema( $this->schema );
}
$this->schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'vehicule',
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'Identifiant unique du véhicule.', 'flotte' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'immatriculation' => array(
'description' => __( "Plaque d'immatriculation.", 'flotte' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'required' => true,
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'kilometrage' => array(
'description' => __( 'Kilométrage relevé.', 'flotte' ),
'type' => 'integer',
'minimum' => 0,
'context' => array( 'view', 'edit' ),
),
),
);
return $this->add_additional_fields_schema( $this->schema );
}
Les clés context déterminent quels champs apparaissent en contexte view (public) ou edit (éditeur). La clé readonly exclut le champ des arguments de création. La clé arg_options permet de surcharger les callbacks de validation et de nettoyage déduits du schéma pour un champ précis.
Tester un contrôleur isolément
Comme chaque ressource est une classe, on peut la tester sans charger le reste de l’extension. Avec la suite de tests de WordPress, la fonction rest_do_request() simule une requête complète, permissions comprises :
class Flotte_Vehicules_Controller_Test extends WP_UnitTestCase {
public function test_liste_refusee_aux_anonymes() {
wp_set_current_user( 0 );
$requete = new WP_REST_Request( 'GET', '/flotte/v1/vehicules' );
$reponse = rest_do_request( $requete );
$this->assertSame( 401, $reponse->get_status() );
}
}
Cas concret et pièges
Dans l’extension de location, la migration s’est faite ressource par ressource : d’abord les véhicules, puis les réservations, en conservant les anciennes routes jusqu’à ce que le front soit mis à jour. Chaque contrôleur est devenu un fichier d’une centaine de lignes, et le fichier de huit cents lignes a disparu sans qu’aucune adresse publique ne change. Quelques pièges reviennent :
- Oublier la clé
schemadansregister_rest_route(): la route fonctionne, mais l’API de découverte ne décrit plus la ressource. - Muter
$this->schemasans précaution. Le schéma est mis en cache dans la propriété ; le retourner viaadd_additional_fields_schema()permet de prendre en compte les champs ajoutés parregister_rest_field()sans le modifier. - Faire entrer de la logique métier dans le contrôleur. Sa tâche est de traduire HTTP en appels à votre code (calcul, dépôt de données) et inversement ; les règles de gestion vivent ailleurs.
- Retourner un objet interne au lieu d’un tableau préparé expose des champs que le schéma n’a jamais déclarés.
Quand ne pas l’utiliser ? Pour une route unique et simple (un point de santé, un webhook), une fonction avec register_rest_route() reste plus lisible : la classe de base n’apporte rien si l’on n’a ni collection, ni schéma, ni plusieurs actions.
Une convention n’est pas un carcan : elle évite simplement de redécouvrir, dans chaque fichier, où l’on range la permission, le schéma et la préparation de la réponse.
Conclusion
WP_REST_Controller n’ajoute pas de fonctionnalité, il ajoute de la lisibilité : une ressource, une classe, des méthodes aux noms prévisibles, un schéma qui documente et valide. C’est la même organisation que celle des contrôleurs du cœur pour les articles ou les utilisateurs, ce qui permet à tout développeur WordPress de s’y retrouver sans guide. Commencez par la ressource la plus encombrante de votre fichier de routes, migrez-la, testez-la, puis passez à la suivante.