# Structurer ses routes avec WP_REST_Controller plutôt que des callbacks

> Un plugin qui expose cinq ressources REST via des fonctions anonymes empilées devient vite illisible. La classe abstraite fournie par le cœur de WordPress structure ce chaos.

- Auteur : WordPress Développement
- Publié le : 2022-09-18
- Mis à jour le : 2026-09-30
- Catégorie : Extensions
- URL : https://www.wpmoderne.fr/extensions/structurer-routes-wp-rest-controller-plutot-callbacks/

## L’essentiel

- WP_REST_Controller impose une convention de méthodes, pas un carcan rigide
- Chaque ressource devient une classe autonome, testable isolément
- Le schéma exposé documente automatiquement la ressource pour l'API découverte

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

> L'essentiel à retenir : WP_REST_Controller impose une convention de méthodes, pas un carcan rigide ; Chaque ressource devient une classe autonome, testable isolément ; Le schéma exposé documente automatiquement la ressource pour l'API découverte

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 retourne `true` ou un `WP_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é `schema`** dans `register_rest_route()` : la route fonctionne, mais l'API de découverte ne décrit plus la ressource.
- **Muter `$this->schema` sans précaution.** Le schéma est mis en cache dans la propriété ; le retourner via `add_additional_fields_schema()` permet de prendre en compte les champs ajoutés par `register_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.
