Construire une API REST avec Laravel : la structure que je recommande

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
15 min de lecture
314 vues

Écouter l'article

Construire une API REST avec Laravel : la structure que je recommande

Prenons un site Laravel dont les fonctionnalités dynamiques (formulaire de contact, compteur de vues, newsletter) passent d'abord par des routes web classiques et des redirections. Ça fonctionne, mais chaque interaction recharge la page entière. Dès qu'on veut ajouter un "like" sur les articles sans rechargement, il faut au minimum un endpoint appelé en JavaScript (route web ou API).

Dans ce cas, on construit une API REST en parallèle de l'application web. Pas une API publique pour des consommateurs externes, mais une API interne consommée par du JavaScript côté front. Il vaut quand même la peine de la structurer proprement, parce que les mauvaises habitudes prises sur une API interne coûtent cher quand le projet grandit.

Voici l'organisation que je recommanderais pour un tel cas, avec les erreurs classiques à éviter. L'API décrite ici est une structure recommandée, pas celle du portfolio (voir la section « Et sur ce portfolio ? » plus bas). Les exemples ciblent Laravel 10 ; les différences avec Laravel 11 et plus sont signalées quand elles existent. Les noms suivent les conventions Laravel, les mêmes que dans les articles sur la sécurité et sur la Clean Architecture : modèle Post au singulier et table posts, PostController, StorePostRequest et UpdatePostRequest, PostResource, PostPolicy, PostService.

La structure des routes : versionner dès le départ

Une API sans préfixe de version, avec toutes les routes directement sous /api/, est piégeuse : le jour où l'on change le format de réponse des articles (ajout des tags, nouvelle structure de l'auteur), on casse le JavaScript qui consommait l'ancien format. Autant préfixer tout avec /v1/ dès le début :

// routes/api.php
use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\PostController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->name('api.v1.')->group(function () {
    // Routes publiques : pas besoin d'authentification
    Route::post('register', [AuthController::class, 'register'])->name('register');
    Route::post('login', [AuthController::class, 'login'])->name('login');

    // Routes protégées : token Sanctum obligatoire
    Route::middleware('auth:sanctum')->group(function () {
        Route::apiResource('posts', PostController::class);
        Route::post('logout', [AuthController::class, 'logout'])->name('logout');
    });
});

Dans Laravel, toutes les routes de routes/api.php reçoivent le préfixe /api, donc l'URL finale est /api/v1/posts. Note de version : un projet neuf en Laravel 11 et plus ne contient plus routes/api.php ; la commande php artisan install:api le crée et installe Sanctum. Le name('api.v1.') donne aux routes des noms cohérents, par exemple api.v1.posts.show. Ces noms servent plus bas dans les API Resources.

apiResource génère les routes index, store, show, update et destroy, sans les routes create et edit (qui n'ont pas de sens sur une API : elles servent à afficher des formulaires HTML). Avec Route::resource(), on se retrouve avec des routes inutiles dans php artisan route:list.

Le versioning par préfixe d'URL est le plus simple. D'autres approches existent (header Accept, query parameter), mais pour une API interne, /v1/ est largement suffisant et rend les choses explicites.

Authentification avec Sanctum

Sanctum est l'outil habituel dans ce cas, et celui des exemples qui suivent (le modèle User doit utiliser le trait Laravel\Sanctum\HasApiTokens). Passport implémente OAuth2 au complet : c'est puissant, mais disproportionné quand on n'a besoin que de tokens API simples. Sanctum est léger et couvre exactement ce besoin pour une SPA ou une API mobile.

Pour une API stateless qui émet des tokens, on ne passe pas par Auth::attempt() : cette méthode utilise le guard par défaut (web), basé sur la session, et ouvre donc une session côté serveur. Elle convient à une SPA qui s'authentifie par cookie (le mode « SPA » de Sanctum), pas à une API par tokens. On vérifie plutôt les identifiants à la main :

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        $user = User::where('email', $credentials['email'])->first();

        if (! $user || ! Hash::check($credentials['password'], $user->password)) {
            return response()->json([
                'message' => 'Invalid credentials',
            ], 401);
        }

        $token = $user->createToken('api-token');

        return response()->json([
            'token' => $token->plainTextToken,
            'user' => new UserResource($user),
        ]);
    }
}

Le message d'erreur en cas de mauvais identifiants mérite attention. Ce qui compte n'est pas la langue ou le libellé exact, mais le fait d'utiliser le même message que l'e-mail soit inconnu ou que le mot de passe soit faux. Un message du type "Aucun compte avec cet e-mail" confirme à un attaquant qu'une adresse existe dans la base. Un message unique et générique, comme "Invalid credentials" ou "E-mail ou mot de passe incorrect", ne donne aucune indication. L'exemple ci-dessus renvoie la même réponse dans les deux cas, avec le même code 401.

Autre subtilité : $token->plainTextToken n'est disponible qu'à la création. Le token est hashé en base, donc impossible de le récupérer après. Si le client le perd, il doit se reconnecter.

API Resources : la couche à mettre en place dès le début

Retourner directement les modèles Eloquent dans les réponses JSON (return response()->json($post)) pose plusieurs problèmes : tous les champs du modèle sont exposés, y compris les colonnes internes que le front n'a pas à connaître, et la forme de la réponse dépend de la structure de la table. Renommer une colonne casse alors le front.

Les API Resources règlent ça en servant de couche de transformation :

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class PostResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'slug' => $this->slug,
            'excerpt' => $this->excerpt,
            'content' => $this->when(
                $request->routeIs('api.v1.posts.show'),
                $this->content
            ),
            'author' => new UserResource($this->whenLoaded('author')),
            'created_at' => $this->created_at->toISOString(),
            'links' => [
                'self' => route('api.v1.posts.show', $this->resource),
            ],
        ];
    }
}

Le nom de route testé par routeIs() est le même que celui passé à route(), et il correspond à celui défini plus haut dans routes/api.php (api.v1.posts.show). Avec un nom différent, le when() ne se déclencherait jamais et le contenu ne sortirait pas, sans aucune erreur.

Deux détails utiles :

$this->when() permet d'inclure le contenu complet uniquement sur la route show. Sur le listing, on n'envoie que l'extrait, ce qui allège la réponse quand chaque article contient un long texte en Markdown.

$this->whenLoaded('author') n'inclut l'auteur que si la relation a été chargée en eager loading. Si on oublie le with('author') dans le controller, la clé author disparaît de la réponse au lieu d'être null, et la Resource ne déclenche pas de requête supplémentaire par article. Le problème N+1 n'est pas résolu pour autant : il reste à charger la relation dans le controller, mais la Resource ne l'aggrave pas.

Pour les collections paginées, on peut utiliser une Resource Collection dédiée. Laravel ajoute lui-même les blocs links et meta de la pagination autour de data : définir une clé meta dans toArray() la fusionne avec celle de Laravel et duplique les valeurs. Dans un essai sur Laravel 10.50 et 12.69, une clé meta ajoutée à la main a produit "total":[2,2], alors que le toArray() minimal ci-dessous donne "total":2. Pour personnaliser ces métadonnées, on définit plutôt une méthode paginationInformation($request, $paginated, $default) sur la collection.

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class PostCollection extends ResourceCollection
{
    public function toArray($request): array
    {
        return [
            'data' => $this->collection,
        ];
    }
}

Le controller : fin, avec une seule responsabilité par méthode

Un controller API de plusieurs centaines de lignes est un symptôme classique : la validation, l'autorisation et la logique métier se sont accumulées au même endroit. La structure ci-dessous répartit ces responsabilités : la validation dans les Form Requests, l'autorisation dans les policies, et la logique métier plus complexe, si elle apparaît, dans un Service ou une Action dédiée.

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostCollection;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function index(Request $request)
    {
        $posts = Post::query()
            ->with('author')
            ->when($request->category, fn ($q, $cat) => $q->where('category', $cat))
            ->when($request->search, fn ($q, $s) => $q->search($s))
            ->latest()
            ->paginate(min((int) $request->query('per_page', 15), 100));

        return new PostCollection($posts);
    }

    public function store(StorePostRequest $request)
    {
        $post = $request->user()->posts()->create($request->validated());

        return response()->json([
            'message' => 'Post created successfully',
            'data' => new PostResource($post),
        ], 201);
    }

    public function show(Post $post)
    {
        $post->load('author', 'comments.user');

        return new PostResource($post);
    }

    public function update(UpdatePostRequest $request, Post $post)
    {
        $this->authorize('update', $post);

        $post->update($request->validated());

        return new PostResource($post);
    }

    public function destroy(Post $post)
    {
        $this->authorize('delete', $post);

        $post->delete();

        return response()->json(null, 204);
    }
}

Choix de conception :

Le when() sur les filtres (category, search) permet de gérer les paramètres optionnels sans if imbriqués. Si le paramètre est absent, la closure n'est pas exécutée. Le search() est ici un scope local défini sur le modèle Post.

Le per_page est plafonné à 100. Sans cette limite, un client peut demander per_page=100000 et forcer le serveur à charger toute la table.

Les codes 201 sur le store et 204 sur le destroy suivent la convention REST : 201 Created avec le body de la ressource créée, 204 No Content sans body pour une suppression. Renvoyer 200 partout fonctionne techniquement, mais complique le côté client (comment distinguer une création d'une mise à jour ?).

La validation est dans des Form Requests dédiés (StorePostRequest, UpdatePostRequest), pas dans le controller. Le controller reste lisible et la validation est testable séparément.

Note de version : $this->authorize() vient du trait AuthorizesRequests, présent dans le controller de base de Laravel 10. Depuis Laravel 11, le controller de base est vide : il faut ajouter le trait, utiliser Gate::authorize('update', $post), ou faire l'autorisation dans la méthode authorize() du Form Request.

La gestion d'erreurs : toujours répondre en JSON

Quand une requête plante, le front doit recevoir une réponse JSON structurée, pas une page HTML d'erreur Laravel. Une API qui répond en HTML donne des erreurs difficiles à comprendre côté JavaScript, parce que response.json() échoue sur du HTML.

Laravel renvoie déjà du JSON pour les erreurs de validation (422), de ressource introuvable (404) et d'autorisation (403) quand le client envoie Accept: application/json. La personnalisation sert à uniformiser le format des messages. Avant Laravel 11, elle se fait dans app/Exceptions/Handler.php, avec renderable() :

// app/Exceptions/Handler.php (Laravel 10)
use Illuminate\Http\Request;
use Throwable;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

public function register(): void
{
    $this->renderable(function (ValidationException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message' => 'Validation failed',
                'errors' => $e->errors(),
            ], 422);
        }
    });

    // ModelNotFoundException est déjà converti en NotFoundHttpException
    // quand les callbacks sont appelés
    $this->renderable(function (NotFoundHttpException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json(['message' => 'Resource not found'], 404);
        }
    });

    // AuthorizationException est converti en AccessDeniedHttpException
    $this->renderable(function (AccessDeniedHttpException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json(['message' => 'Forbidden'], 403);
        }
    });
}

// Forcer le JSON pour tout ce qui est sous /api, même sans en-tête Accept
protected function shouldReturnJson($request, Throwable $e): bool
{
    return $request->is('api/*') || $request->expectsJson();
}

Sur Laravel 10, Handler::render() convertit d'abord ModelNotFoundException et AuthorizationException en exceptions HTTP (prepareException()), puis appelle les callbacks renderable() : c'est pourquoi les callbacks ci-dessus attendent NotFoundHttpException et AccessDeniedHttpException. La méthode protégée shouldReturnJson() décide aussi du format de la réponse 401.

Depuis Laravel 11, il n'y a plus de classe Handler : la même configuration se fait dans bootstrap/app.php, avec withExceptions :

// bootstrap/app.php (Laravel 11 et plus)
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (NotFoundHttpException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json(['message' => 'Resource not found'], 404);
        }
    });

    // Forcer le JSON pour tout ce qui est sous /api, même sans en-tête Accept
    $exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
        return $request->is('api/*') || $request->expectsJson();
    });
})

Le $request->expectsJson() vérifie l'en-tête Accept: application/json. C'est la condition clé : si le client demande du JSON, on répond en JSON, même en cas d'erreur. Sans cette vérification (ou sans shouldRenderJsonWhen), une requête API qui échoue sans le bon header peut recevoir la page d'erreur HTML de Laravel.

Les codes HTTP comptent. 422 pour la validation (plutôt que 400 : 422 Unprocessable Entity dit "la requête est comprise mais les données sont invalides"), 404 quand la ressource n'existe pas, 403 quand l'utilisateur est authentifié mais n'a pas le droit, 401 quand il n'est pas authentifié. Côté front, chaque code a son traitement : un 422 affiche les erreurs de validation sous les champs, un 401 redirige vers la page de login, un 403 affiche un message "action non autorisée", un 404 affiche un message "article introuvable".

À propos du 401 : sans l'en-tête Accept: application/json, le middleware d'authentification tente de rediriger vers la route nommée login, ce qui produit une redirection ou une erreur au lieu d'un JSON. C'est une autre raison de forcer le JSON sur les routes d'API.

Le versioning en pratique

Quand une v2 devient nécessaire, on peut séparer les fichiers de routes :

// V1 : l'existant, ne change plus
Route::prefix('v1')->name('api.v1.')->group(base_path('routes/api/v1.php'));

// V2 : la nouvelle version
Route::prefix('v2')->name('api.v2.')->group(base_path('routes/api/v2.php'));

La v1 reste active pour ne pas casser les clients existants. La v2 introduit les changements qu'on ne peut pas faire de manière rétrocompatible.

Il ne faut créer une v2 que quand c'est réellement nécessaire (changement de structure de réponse, suppression de champs). Pour un ajout de champ, la v1 suffit : ajouter un champ à une réponse JSON ne casse rien côté client.

La documentation : Scribe

Un fichier Markdown de documentation tenu à la main devient vite obsolète : on ajoute des paramètres de filtre sans le mettre à jour. Scribe est un outil possible : il génère la documentation à partir du code.

composer require --dev knuckleswtf/scribe
php artisan scribe:generate

Une fois installé, il analyse les routes, les Form Requests et les API Resources, et génère une page HTML avec des exemples de requêtes cURL. Ce n'est pas parfait : il faut parfois ajouter des annotations PHPDoc pour les cas complexes. C'est tout de même plus fiable qu'une doc manuelle qu'on oublie de mettre à jour.

Et sur ce portfolio ?

Le portfolio n'a pas cette API. Son fichier routes/api.php ne contient qu'une seule route, GET /api/palette.json, qui alimente la palette de commandes (Ctrl+K) avec des titres et des URLs de contenus publiés. Elle est soumise au rate limiter api de Laravel et sa réponse est mise en cache. Il n'y a ni Sanctum, ni API Resources, ni préfixe /v1 : le site fonctionne avec des routes web classiques, des formulaires protégés par CSRF et quelques appels fetch vers ces mêmes routes. La structure décrite dans cet article est donc celle qui serait mise en place le jour où une vraie API deviendrait nécessaire, et les outils cités (Sanctum, Scribe) sont des outils possibles, pas des composants en service sur le site.

À mettre en place dès le premier jour

Pour une API à partir de zéro, il faut mettre en place dès le départ les API Resources (pas de return $model direct), la gestion d'erreurs JSON et le préfixe de version. Ce sont les éléments les plus coûteux à ajouter après coup, parce qu'ils obligent à modifier le code front en parallèle.

Une API interne mérite autant de rigueur qu'une API publique. Renvoyer des modèles Eloquent bruts « parce que c'est le seul client » expose tous leurs champs. Le jour où l'on ajoute une colonne sensible au modèle User (un password_reset_token, par exemple), elle part dans toutes les réponses JSON, sauf si une Resource filtre ce qui est exposé.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.