Retour au cours

backend / php-laravel

API REST avec Laravel : ressources et versionnement

Leçon 171 exercice

Explication

Ce que vous allez apprendre

  • Découpler la forme des réponses JSON de la structure interne des modèles avec JsonResource
  • Éviter des requêtes inutiles ou l'exposition de données sensibles avec whenLoaded, whenCounted et when
  • Structurer une collection de ressources avec ses métadonnées de pagination
  • Versionner une API par préfixe d'URL pour faire évoluer son contrat sans casser les clients existants
  • Centraliser la gestion des exceptions pour renvoyer des réponses JSON cohérentes plutôt que des pages HTML

Dans quel contexte ?

Une API de boutique en ligne est consommée à la fois par une application mobile et un site web tiers partenaire. Le modèle Produit en base contient un champ cout_achat que seuls les administrateurs internes doivent voir, jamais les clients externes. Sans une couche de transformation dédiée, cette information sensible risquerait de fuiter dans une réponse JSON un jour où quelqu'un ajoute négligemment Produit::all() directement dans une réponse.

D'abord, ne jamais exposer directement un modèle Eloquent en JSON

Convertir un modèle Eloquent directement en JSON expose tous ses attributs sans distinction, y compris ceux qui ne devraient jamais quitter le serveur. Une classe JsonResource (ProduitResource) définit explicitement, champ par champ, ce qui doit apparaître dans la réponse — une liste blanche, comme $fillable protège l'écriture, protège ici la lecture.

Ce découplage a un second avantage : la structure de la table produits peut évoluer (renommer une colonne, en fusionner deux) sans jamais casser le contrat JSON exposé aux clients, tant que la Resource continue de produire la même forme de sortie.

Prérequis

Cette leçon suppose une bonne maîtrise des relations Eloquent (leçon 11), puisque les Resources gèrent explicitement l'affichage conditionnel de ces relations.

Ensuite, éviter de charger ou d'exposer plus que nécessaire

whenLoaded('categorie') n'inclut la catégorie dans la réponse QUE si elle a été explicitement chargée en amont (via with('categorie')) — sans ça, Eloquent déclencherait une requête supplémentaire à la demande, recréant exactement le problème N+1 vu en leçon 11. when($condition, ...) va plus loin : il n'inclut un champ que si une condition métier est vraie, comme le rôle de l'utilisateur courant.

MéthodeInclut le champ si...
whenLoaded('relation')La relation a été eager-loaded en amont
whenCounted('relation')Un withCount() a été appliqué en amont
when($condition, ...)Une condition métier arbitraire est vraie

Piège fréquent

Appeler $produit->categorie directement (sans whenLoaded) dans une Resource déclenche une requête à chaque produit de la collection si la relation n'a pas été préchargée, réintroduisant silencieusement un problème N+1 malgré tout le soin apporté ailleurs.

Il reste à faire évoluer une API sans casser ses clients existants

Une fois qu'une API est consommée par des clients externes (application mobile publiée, partenaires), changer brutalement la forme d'une réponse casserait leur intégration. Le versionnement par préfixe d'URL (/api/v1, /api/v2) permet de faire coexister deux versions du contrat, le temps que tous les clients migrent vers la nouvelle.

Maintenant, des erreurs API cohérentes plutôt que des pages HTML

Par défaut, certaines exceptions Laravel produisent une page d'erreur HTML — totalement inutilisable pour un client qui attend du JSON. $exceptions->render(...) dans bootstrap/app.php permet d'intercepter des types précis d'exceptions et de renvoyer systématiquement du JSON structuré pour toute requête sous /api/*.

Astuce

Centraliser cette conversion d'exceptions en un seul endroit évite de dupliquer des blocs try/catch dans chaque contrôleur juste pour formatter une erreur — le code métier reste concentré sur la logique, pas sur la mise en forme des erreurs.

Maintenant que l'API expose des données de façon sûre et cohérente, la prochaine leçon aborde comment déplacer du travail coûteux (envoi d'emails, génération de PDF) hors du cycle de requête HTTP grâce aux jobs et aux queues.

Commandes & code

API REST avec Laravel : ressources et versionnement

php
<?php
// app/Http/Resources/ProduitResource.php : transforme le modèle en JSON contrôlé
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProduitResource extends JsonResource {
    public function toArray(Request $request): array {
        return [
            'id' => $this->id,
            'nom' => $this->nom,
            'prix' => (float) $this->prix,
            'disponible' => $this->est_disponible,
            // Relation chargée conditionnellement (whenLoaded évite une requête si non eager-loaded)
            'categorie' => new CategorieResource($this->whenLoaded('categorie')),
            'avis_count' => $this->whenCounted('avis'),
            // Champ visible seulement si l'utilisateur courant est propriétaire
            'cout_achat' => $this->when(
                $request->user()?->hasRole('admin'),
                fn () => (float) $this->cout_achat
            ),
            'liens' => [
                'self' => route('produits.show', $this->id),
            ],
        ];
    }
}

// Collection de ressources avec métadonnées de pagination
class ProduitCollection extends \Illuminate\Http\Resources\Json\ResourceCollection {
    public function toArray(Request $request): array {
        return [
            'data' => $this->collection,
            'meta' => [
                'total' => $this->total(),
                'page' => $this->currentPage(),
                'par_page' => $this->perPage(),
            ],
        ];
    }
}
php
<?php
namespace App\Http\Controllers\Api\V1;

use App\Http\Resources\{ProduitResource, ProduitCollection};
use App\Models\Produit;

class ProduitController extends Controller {
    public function index() {
        $produits = Produit::withCount('avis')->with('categorie')->paginate(20);
        return new ProduitCollection($produits);
    }

    public function show(Produit $produit) {
        return new ProduitResource($produit->load('categorie'));
    }
}
php
<?php
// routes/api.php : versionnement par préfixe d'URL et namespace de contrôleurs
Route::prefix('v1')->name('api.v1.')->group(function () {
    Route::apiResource('produits', \App\Http\Controllers\Api\V1\ProduitController::class);
});

Route::prefix('v2')->name('api.v2.')->group(function () {
    Route::apiResource('produits', \App\Http\Controllers\Api\V2\ProduitController::class);
});

// Gestion centralisée des erreurs API (bootstrap/app.php)
use Illuminate\Foundation\Configuration\Exceptions;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (\Illuminate\Validation\ValidationException $e, $request) {
        if ($request->is('api/*')) {
            return response()->json(['erreurs' => $e->errors()], 422);
        }
    });

    $exceptions->render(function (\Illuminate\Database\Eloquent\ModelNotFoundException $e, $request) {
        if ($request->is('api/*')) {
            return response()->json(['erreur' => 'Ressource introuvable'], 404);
        }
    });
});

Résumé

  • JsonResource découple la forme des réponses JSON de la structure interne des modèles.
  • whenLoaded / whenCounted / when évitent des requêtes inutiles ou l'exposition de données sensibles.
  • Le versionnement par préfixe d'URL (/api/v1, /api/v2) permet de faire évoluer l'API sans casser les clients existants.
  • Centraliser la gestion des exceptions API renvoie des réponses JSON cohérentes plutôt que des pages HTML.

Exercices pratiques

1 disponible
1

Mission : colmater une fuite de marge dans l'API publique

Objectif : Corriger une Resource API qui expose une donnee sensible et reintroduit un probleme N+1, puis centraliser la gestion des erreurs.

Contexte

La classe ProduitResource d'une API consommee par un partenaire externe contient la ligne 'cout_achat' => (float) $this->cout_achat, sans aucune condition, alors que ce champ ne devrait etre visible que par les administrateurs internes. Par ailleurs, la meme Resource accede directement a $this->categorie->nom (sans whenLoaded), ce qui declenche une requete SQL supplementaire pour chaque produit de la collection paginee. Enfin, une ModelNotFoundException sur un produit inexistant renvoie actuellement une page d'erreur HTML complete au lieu d'un JSON exploitable par le partenaire.

Résoudre l’exercice →