Rutes API

Com definir rutes API a Laravel: prefixos, versionat, controladors API, respostes JSON i gestió d'errors.

El fitxer de rutes API#

Les rutes d'API es defineixen al fitxer routes/api.php, que és independent de routes/web.php. Aquesta separació no és merament organitzativa: cada fitxer aplica un conjunt de middleware diferent. Les rutes web inclouen middleware per a sessions, cookies, CSRF i estat. Les rutes API, en canvi, són stateless per naturalesa: no mantenen cap estat entre peticions. Cada petició s'autentica de manera independent, normalment amb un token enviat a la capçalera Authorization.

Aquesta diferència és fonamental per entendre com funciona una API REST. El servidor no recorda qui ets entre peticions. Cada petició ha d'incloure tota la informació necessària per autenticar-se i processar-se. Això fa que les APIs siguin més escalables (no cal compartir estat entre servidors) i més fàcils de testejar (cada petició és autosuficient).

A Laravel 11, el fitxer routes/api.php no existeix per defecte. Laravel ho fa així perquè no totes les aplicacions necessiten una API, i és millor no carregar middleware innecessari. Per crear-lo, executa:

php artisan install:api

Aquesta comanda crea el fitxer de rutes, instal·la Sanctum i configura el middleware corresponent. Un cop fet, pots definir les rutes de la teva API:

// routes/api.php
use App\Http\Controllers\Api\ArticleController;
use App\Http\Controllers\Api\CommentController;
use Illuminate\Http\Request;
 
Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');
 
Route::apiResource('articles', ArticleController::class);
Route::apiResource('articles.comments', CommentController::class);

Totes les rutes definides aquí seran accessibles amb el prefix /api. Per exemple, Route::get('/articles', ...) respon a GET /api/articles.

Controladors API#

Una API no serveix formularis HTML, així que els mètodes create i edit d'un controlador de recursos no tenen sentit: en una aplicació web, create mostra el formulari per crear un recurs i edit mostra el formulari per editar-lo, però una API simplement accepta dades JSON als endpoints store i update.

Laravel proporciona el flag --api per crear controladors sense aquests dos mètodes:

php artisan make:controller Api/ArticleController --api --model=Article

Això genera un controlador amb els cinc mètodes estàndard d'una API REST:

namespace App\Http\Controllers\Api;
 
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreArticleRequest;
use App\Http\Requests\UpdateArticleRequest;
use App\Http\Resources\ArticleResource;
use App\Models\Article;
use Illuminate\Http\Request;
 
class ArticleController extends Controller
{
    public function index(Request $request)
    {
        $articles = Article::query()
            ->with('author')
            ->when($request->search, function ($query, $search) {
                $query->where('title', 'like', "%{$search}%");
            })
            ->when($request->tag, function ($query, $tag) {
                $query->whereHas('tags', fn ($q) => $q->where('slug', $tag));
            })
            ->latest()
            ->paginate($request->input('per_page', 15));
 
        return ArticleResource::collection($articles);
    }
 
    public function store(StoreArticleRequest $request)
    {
        $article = $request->user()->articles()->create($request->validated());
 
        return new ArticleResource($article);
    }
 
    public function show(Article $article)
    {
        return new ArticleResource(
            $article->load(['author', 'tags', 'comments.author'])
        );
    }
 
    public function update(UpdateArticleRequest $request, Article $article)
    {
        $article->update($request->validated());
 
        return new ArticleResource($article->fresh());
    }
 
    public function destroy(Article $article)
    {
        $article->delete();
 
        return response()->noContent();
    }
}

Fixa't en diversos patrons importants d'aquest controlador. El mètode index utilitza when() per aplicar filtres condicionalment, cosa que permet que el client filtri per cerca o etiqueta només si passa els paràmetres corresponents. El mètode store crea l'article a través de la relació articles() de l'usuari, assignant automàticament el user_id. El mètode show carrega les relacions necessàries amb load perquè l'API Resource pugui incloure-les a la resposta. El mètode update utilitza fresh() per tornar a carregar el model des de la base de dades, assegurant que la resposta reflecteix l'estat real després de l'actualització (incloent mutadors, valors per defecte i camps calculats). El mètode destroy retorna una resposta 204 No Content amb noContent(), que és l'estàndard REST per a eliminacions: el recurs s'ha eliminat i no hi ha res a retornar.

Un altre detall important és l'ús de Form Requests (StoreArticleRequest, UpdateArticleRequest) per a la validació. Quan la validació falla en una petició API, Laravel retorna automàticament una resposta 422 amb els errors en format JSON, sense necessitat de gestionar-ho manualment.

Rutes apiResource#

El mètode apiResource és la manera més concisa de registrar les cinc rutes REST estàndard per a un recurs. Amb una sola línia de codi, Laravel genera les rutes per llistar, crear, veure, actualitzar i eliminar:

Route::apiResource('articles', ArticleController::class);
 
// Equivalent a:
// GET    /api/articles          → index
// POST   /api/articles          → store
// GET    /api/articles/{article} → show
// PUT    /api/articles/{article} → update
// DELETE /api/articles/{article} → destroy

Per registrar múltiples resources alhora:

Route::apiResources([
    'articles' => ArticleController::class,
    'categories' => CategoryController::class,
    'tags' => TagController::class,
]);

Resources imbricats#

Quan un recurs pertany a un altre (per exemple, els comentaris pertanyen a un article), pots reflectir aquesta relació a les URLs amb resources imbricats. Això crea URLs jeràrquiques que expressen la relació entre els recursos:

// GET    /api/articles/{article}/comments
// POST   /api/articles/{article}/comments
// GET    /api/articles/{article}/comments/{comment}
// PUT    /api/articles/{article}/comments/{comment}
// DELETE /api/articles/{article}/comments/{comment}
Route::apiResource('articles.comments', CommentController::class);

Laravel verifica automàticament que el comentari pertany a l'article indicat (scoping implícit). Si intentes accedir a /api/articles/1/comments/999 i el comentari 999 no pertany a l'article 1, Laravel retorna un 404. El controlador rep ambdós models com a paràmetres:

class CommentController extends Controller
{
    public function index(Article $article)
    {
        return CommentResource::collection(
            $article->comments()->with('author')->latest()->paginate(20)
        );
    }
 
    public function store(StoreCommentRequest $request, Article $article)
    {
        $comment = $article->comments()->create([
            ...$request->validated(),
            'user_id' => $request->user()->id,
        ]);
 
        return new CommentResource($comment);
    }
 
    public function show(Article $article, Comment $comment)
    {
        return new CommentResource($comment->load('author'));
    }
 
    public function update(UpdateCommentRequest $request, Article $article, Comment $comment)
    {
        $comment->update($request->validated());
 
        return new CommentResource($comment->fresh());
    }
 
    public function destroy(Article $article, Comment $comment)
    {
        $comment->delete();
 
        return response()->noContent();
    }
}

Les URLs completament imbricades poden ser llargues i redundants. Si un comentari té un ID únic global, no cal incloure l'article a les rutes de detall, actualització i eliminació. L'opció shallow() manté l'imbricació per a les rutes que realment la necessiten (llistar i crear, on cal saber l'article pare) i utilitza URLs curtes per a la resta:

Route::apiResource('articles.comments', CommentController::class)->shallow();
 
// Genera:
// GET    /api/articles/{article}/comments       → index
// POST   /api/articles/{article}/comments       → store
// GET    /api/comments/{comment}                → show
// PUT    /api/comments/{comment}                → update
// DELETE /api/comments/{comment}                → destroy

Versionat d'API#

A mesura que l'aplicació evoluciona, tard o d'hora necessitaràs fer canvis a l'API que trencarien la compatibilitat amb clients existents: canviar l'estructura d'una resposta, renombrar un camp, eliminar un endpoint o modificar el comportament d'un paràmetre. Sense versionat, qualsevol canvi que trenqui el contracte de l'API pot deixar inservibles les aplicacions mòbils, SPAs o integracions de tercers que la consumeixen.

El versionat permet mantenir la versió anterior de l'API funcionant mentre ofereixes la nova. Els clients existents segueixen funcionant amb la versió que coneixen, i els nous clients poden adoptar la versió més recent. L'estratègia més comuna i senzilla és el versionat per URL:

// routes/api.php
 
// Versió 1
Route::prefix('v1')->group(function () {
    Route::apiResource('articles', Api\V1\ArticleController::class);
    Route::apiResource('users', Api\V1\UserController::class);
});
 
// Versió 2
Route::prefix('v2')->group(function () {
    Route::apiResource('articles', Api\V2\ArticleController::class);
    Route::apiResource('users', Api\V2\UserController::class);
});

Organitza els controladors en carpetes per versió:

app/Http/Controllers/Api/
├── V1/
│   ├── ArticleController.php
│   └── UserController.php
└── V2/
    ├── ArticleController.php
    └── UserController.php

De la mateixa manera, pots tenir Resources separats per versió:

app/Http/Resources/
├── V1/
│   └── ArticleResource.php
└── V2/
    └── ArticleResource.php

Una altra estratègia és el versionat per capçalera, on el client indica la versió a la capçalera Accept:

Accept: application/vnd.myapp.v2+json

Però el versionat per URL és més senzill d'implementar i depurar, i és l'opció més comuna a la pràctica.

Respostes JSON#

Una API ben dissenyada utilitza els codis d'estat HTTP de manera consistent per comunicar el resultat de cada operació. El client no hauria de necessitar inspeccionar el cos de la resposta per saber si una petició ha tingut èxit o no: el codi d'estat ho indica immediatament. Els codis més comuns en una API REST són 200 (èxit), 201 (recurs creat), 204 (sense contingut, per a eliminacions), 400 (petició incorrecta), 401 (no autenticat), 403 (sense permís), 404 (no trobat), 422 (errors de validació) i 429 (massa peticions).

Respostes d'èxit#

Laravel converteix automàticament arrays, col·leccions Eloquent i models a JSON quan es retornen des d'una ruta. Tot i així, és recomanable ser explícit amb els codis d'estat HTTP per seguir les convencions REST:

// 200 OK (per defecte)
return response()->json(['data' => $articles]);
 
// 201 Created (per a POST)
return response()->json([
    'data' => new ArticleResource($article),
    'message' => 'Article creat correctament.',
], 201);
 
// 204 No Content (per a DELETE)
return response()->noContent();
 
// Amb capçaleres personalitzades
return response()->json($data)
    ->header('X-Total-Count', $total)
    ->header('X-Request-Id', Str::uuid());

Estructura consistent#

Una bona pràctica és mantenir una estructura JSON consistent a totes les respostes. Les API Resources de Laravel ja embolcallen les dades dins d'una clau data, però per a respostes manuals convé seguir el mateix patró:

// Resposta d'èxit amb dades
{
    "data": { ... },
    "message": "Article creat correctament."
}
 
// Resposta d'èxit amb llista paginada
{
    "data": [ ... ],
    "links": {
        "first": "http://api.example.com/articles?page=1",
        "last": "http://api.example.com/articles?page=5",
        "prev": null,
        "next": "http://api.example.com/articles?page=2"
    },
    "meta": {
        "current_page": 1,
        "last_page": 5,
        "per_page": 15,
        "total": 73
    }
}
 
// Resposta d'error
{
    "message": "Article no trobat.",
    "errors": {}
}

Quan les API Resources retornen una col·lecció paginada, Laravel inclou automàticament les claus links i meta amb la informació de paginació.

Gestió d'errors#

La gestió d'errors és una part crítica del disseny d'una API. Un bon tractament dels errors facilita que els clients sàpiguen exactament què ha fallat i com corregir-ho. Laravel gestiona molts errors automàticament (validació, models no trobats, autenticació), però convé personalitzar els missatges per oferir una experiència consistent i clara.

Forçar respostes JSON#

Per defecte, Laravel decideix el format de la resposta d'error segons la capçalera Accept de la petició. Si la petició no inclou Accept: application/json, Laravel pot retornar una pàgina HTML d'error (per exemple, la pàgina 404 de Blade) en lloc de JSON. Això és un problema habitual quan es testa l'API amb eines que no envien aquesta capçalera per defecte, o quan un client mòbil o frontend no la configura correctament.

Per garantir que les rutes API sempre retornin JSON, independentment de la capçalera Accept, pots crear un middleware que la forci:

->withMiddleware(function (Middleware $middleware) {
    $middleware->api(prepend: [
        \App\Http\Middleware\ForceJsonResponse::class,
    ]);
})
// app/Http/Middleware/ForceJsonResponse.php
namespace App\Http\Middleware;
 
use Closure;
use Illuminate\Http\Request;
 
class ForceJsonResponse
{
    public function handle(Request $request, Closure $next)
    {
        $request->headers->set('Accept', 'application/json');
 
        return $next($request);
    }
}

Errors de validació#

Quan la validació falla en una petició API (amb capçalera Accept: application/json), Laravel retorna automàticament una resposta 422 Unprocessable Entity. Aquest codi d'estat indica que el servidor ha entès la petició, però les dades enviades no compleixen les regles de validació. La resposta inclou un objecte errors amb els missatges per cada camp que ha fallat:

{
    "message": "The title field is required. (and 1 more error)",
    "errors": {
        "title": [
            "The title field is required."
        ],
        "body": [
            "The body field must be at least 10 characters."
        ]
    }
}

No cal fer res per obtenir aquest comportament: si utilitzes Form Requests o el mètode validate() al controlador, Laravel gestiona els errors de validació automàticament per a peticions JSON.

Errors de model no trobat#

Quan un model no es troba (per exemple, accedir a /api/articles/999 quan l'article 999 no existeix), Laravel retorna automàticament un 404. El missatge per defecte, però, revela informació interna com el nom de la classe del model:

{
    "message": "No query results for model [App\\Models\\Article] 999"
}

Pots personalitzar el missatge al bootstrap/app.php:

use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (NotFoundHttpException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'message' => 'Recurs no trobat.',
            ], 404);
        }
    });
})

Errors d'autorització#

Quan un usuari no té permís (403):

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (AuthorizationException $e, Request $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'message' => $e->getMessage() ?: 'No tens permís per fer aquesta acció.',
            ], 403);
        }
    });
})

CORS#

Quan el frontend i l'API estan en dominis o ports diferents (per exemple, el frontend a localhost:3000 i l'API a localhost:8000), el navegador bloqueja les peticions per seguretat. Això es coneix com la política de Same-Origin. Per permetre que el frontend faci peticions a l'API, cal configurar CORS (Cross-Origin Resource Sharing) al fitxer config/cors.php:

// config/cors.php
return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['*'],
    'allowed_origins' => [
        'http://localhost:3000',
        'http://localhost:5173',
        'https://app.example.com',
    ],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [
        'X-RateLimit-Limit',
        'X-RateLimit-Remaining',
    ],
    'max_age' => 0,
    'supports_credentials' => false,
];

L'array allowed_origins llista els dominis que poden fer peticions a l'API. En producció, no utilitzis ['*'] ja que permetria peticions des de qualsevol domini. L'array exposed_headers indica quines capçaleres de resposta pot llegir el JavaScript del client (per defecte, el navegador només exposa un conjunt limitat de capçaleres). Si utilitzes autenticació basada en cookies (com l'autenticació SPA de Sanctum), configura supports_credentials a true.

Grups de rutes amb middleware#

La majoria d'APIs tenen una combinació de rutes públiques i protegides. Les rutes públiques (llistar articles, veure un article) no requereixen autenticació perquè les dades són accessibles per a tothom. Les rutes protegides (crear, editar, eliminar) requereixen que l'usuari estigui autenticat. El patró habitual és separar-les en grups amb middleware diferent:

// routes/api.php
 
// Rutes públiques
Route::get('/articles', [ArticleController::class, 'index']);
Route::get('/articles/{article}', [ArticleController::class, 'show']);
Route::get('/categories', [CategoryController::class, 'index']);
 
// Rutes autenticades
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/user', function (Request $request) {
        return new UserResource($request->user());
    });
 
    Route::post('/articles', [ArticleController::class, 'store']);
    Route::put('/articles/{article}', [ArticleController::class, 'update']);
    Route::delete('/articles/{article}', [ArticleController::class, 'destroy']);
 
    Route::apiResource('comments', CommentController::class)
        ->except(['index', 'show']);
});

Pots combinar múltiples middlewares:

Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    Route::apiResource('articles', ArticleController::class);
});

Aquesta separació és important no només per la seguretat (que els endpoints de creació i eliminació requereixin autenticació) sinó també per la claredat del codi. Qualsevol desenvolupador que miri el fitxer de rutes pot entendre immediatament quines rutes són públiques i quines necessiten un token.

Llistar rutes API#

Durant el desenvolupament, és útil poder veure totes les rutes registrades de l'API per verificar que la configuració és correcta:

php artisan route:list --path=api
 
# Amb més detalls
php artisan route:list --path=api -v

Això mostra el mètode HTTP, la URI, el nom de la ruta, el controlador i els middlewares aplicats. És útil per verificar que les rutes estan configurades correctament.