Je développe des API avec Laravel depuis des années maintenant, et une chose est claire : les bonnes pratiques sont pas assez mise en valeurs par le framework et la communauté. La plupart des tutoriels pour débutants se concentrent encore sur les applications full-stack avec des vues Blade, de l’authentification et des pages rendues coté serveur. C’est très bien pour comprendre les fondamentaux de Laravel, mais la réalité du développement moderne est différente.

Aujourd’hui, Laravel c’est surtout du backend. Il alimente les frontends: React, les applications Vue, les applications mobiles et les intégrations tierces. Il à besoin de renvoyer du JSON, pas de l’HTML. Comprendre comment créer des API propres et faciles à maintenir n’est plus une compétence facultative, c’est devenu la base.

Ce guide présente huit projets conçus pour vous enseigner le développement d’API avec Laravel. Chaque projet s’appuie sur les concepts des précédents, introduisant de nouveaux concepts et défis auxquels vous serez confronté lors du développement d’application.

Je ne vais pas vous mentir : certains d’entre eux vous frustreront. C’est une bonne chose. Si vous vous débattez avec ces concepts maintenant, vous n’aurez pas à les apprendre plus tard sous la pression des délais.

Les projets

Avant de nous lancer, parlons de ce qui différencie ces projets des tutoriels classiques destinés aux débutants.

Tout d’abord, ils sont axés sur les API. Vous ne créerez pas une seule vue Blade. Chaque interaction se fait via des endpoint HTTP renvoyant du JSON. Cela vous oblige à réfléchir à la gestion des états, à l’authentification et à la transformation des données d’une manière différente de celle que vous utiliseriez dans une application web traditionnelle.

Deuxièmement, ils sont progressifs. Le projet n° 1 enseigne les bases du CRUD. Le projet n°8 implique de la recherche, de la protection contre l’abus et une gestion complexe des états. Ne sautez pas d’étapes. Les modèles que vous apprenez au début s’associent pour former des projets plus complexes.

Troisièmement, ils sont incomplets par conception. Je vous donne le cadre et les concepts clés, mais je ne vais pas vous tenir la main à chaque ligne de code. Vous devrez lire la documentation, prendre des décisions et parfois refactoriser lorsque vous vous rendrez compte que votre première approche n’était pas idéale. Ce n’est pas un bug, c’est ainsi que vous apprenez réellement.

1. Apprendre à penser en Resources

Pour commencer, nous allons créer une simple API de gestion de tâches. Le but est de comprendre l’utilisation des Resources de Laravel et comment Laravel gère les réponses de l’API.

Les chemins d’accès

GET    /api/tasks       # Lister toutes les tâches
POST   /api/tasks       # Créer une nouvelle tâche
GET    /api/tasks/{id}  # Afficher une tâche spécifique
PUT    /api/tasks/{id}  # Mettre à jour une tâche
DELETE /api/tasks/{id}  # Supprimer une tâche

Rien de fou dans les URI, on respect la convention REST.

L’une des premières erreur que font les débutants sur Laravel est de renvoyer directement le Model:

public function index()
{
	return Task::all();
}

Même si cette approche fonctionne, elle soulève plusieurs problèmes. Elle expose le schéma de votre base de données. Si vous avez des informations confidentielles celle-ci se retrouve dans le payload (Ex: mot de passe hashé, clés d’apis, etc). Si vous changez une colonne en base, alors il faudra répliquer le changement sur toutes les applications qui l’utilise. Pour résumer, vous n’avez pas de contrôle sur ce que vous envoyez. La base de donnée à ce contrôle.

C’est pour cela que les Resources existent. Elles jouent le rôle de transformateur entre le Model et la réponse de l’API.

namespace App\Http\Resources;
 
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
 
class TaskResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'completed' => (bool) $this->completed,
            'completed_at' => $this->completed_at?->toISOString(),
            'created_at' => $this->created_at->toISOString(),
            'updated_at' => $this->updated_at->toISOString(),
        ];
    }
}

Ici, on peut gérer les valeurs, le format des dates, les type de valeurs que l’on retourne. Et votre Controller ressemble maintenant à cela:

public function index()
{
    return TaskResource::collection(
        Task::all()
    );
}
 
public function show(Task $task)
{
    return new TaskResource($task);
}

Maintenant, votre API change uniquement quand vous le souhaitez et quand vous le déclarer dans votre Resources.

Validez vos données !

Ne laisser pas les utilisateurs de votre API, écrire des données que vous ne souhaitez pas en base !

Pour vous aider Laravel propose les FormRequest. Cela vous évite de réinventer la roue à chaque fois que vous voulez valider la présence, le type et/ou bien la valeur du payload entrant.

Un autre mauvaise pratique est de valider manuellement les données dans votre Controller. Quand votre application grossira votre serez content de ne pas avoir des Controller qui font 1000 lignes, en plus de respecter le S de Principes SOLID (PHP).

namespace App\Http\Requests;
 
use Illuminate\Foundation\Http\FormRequest;
 
class StoreTaskRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }
 
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'description' => ['nullable', 'string'],
            'completed' => ['boolean'],
        ];
    }
}
 

Pour les curieux, voici une liste de toutes les règles de validation.

Avec cette solution votre Controller ressemble à ça:

public function store(StoreTaskRequest $request)
{
    $task = Task::create($request->validated());
    
    return new TaskResource($task);
}

Si la validation échoue, Laravel retourne automatiquement une 422 avec les erreurs de validations dans le payload.

2. Les relations pour les null ❤️

Maintenant que vous savez comment présenter votre Model dans votre API, voyons comment gérer les relations entre vos Model, éviter le problème de performance le plus commun.

Dans cet exemple, nous avons des posts qui appartient à une catégories. Des auteurs qui écrivent ces posts.

La base de donnée

Schema::create('categories', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->timestamps();
});
 
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->foreignId('category_id')->constrained();
    $table->foreignId('user_id')->constrained();
    $table->string('title');
    $table->string('slug')->unique();
    $table->text('content');
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
});

Les Models

class Post extends Model
{
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }
    
    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}
 
class Category extends Model
{
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
    }
}

Rien de particulier ici, on déclare les relations entre nos Models. Pour la simplicité, du projet je ne montre pas la relation dans le Model User.

Le problème des N+1

Si on suit les conseils du point précédent, voilà à quoi ressemble votre Controller:

public function index()
{
    $posts = Post::all();
    
    return PostResource::collection($posts);
}

Pourquoi cet un problème, me diriez-vous ? Si votre PostResource retourne le nom de la catégorie (ou n’importe quelle de ses valeurs), Laravel fera ces requêtes à la base de donnée:

  1. Récupère tous les posts
  2. Récupère la catégorie avec l’id qui est égale au category_id du post 1.
  3. Récupère la catégorie avec l’id qui est égale au category_id du post 2.
  4. Récupère la catégorie avec l’id qui est égale au category_id du post 3.
  5. … Vous avez compris, si vous avez 100 posts alors vous aurez 101 requêtes vers votre base de données. Surtout que certaines catégories se retrouvent demandés plusieurs fois.

Pour régler cela, Laravel proposer de charger des relations lui même:

public function index()
{
    $posts = Post::with(['category', 'author'])->get();
    
    return PostResource::collection($posts);
}

En fond, Laravel fait les actions suivantes:

  1. Récupère tous les posts en base de données
  2. Boucle sur tous les posts pour récupérer les id uniques de catégorie
  3. Récupère les catégories dont l’id es présent dans la liste récupérée juste avant
  4. Boucle sur tous les posts pour ajouter la catégorie correspondante dans les valeurs.
  5. Même chose pour les auteurs

Au final, nous nous retrouvons avec uniquement 3 appels à la base de données.

Si vous voulez débugger facilement vos requêtes à la base de donnée, vous pouvez ajouter ce code dans le AppServiceProvider:

if (App::environment('local')) {
	DB::listen(function ($query) {
	    Log::info($query->sql);
	});
}

Pour l’environnement de production je conseil ce petit bout de code: Logger les requêtes en base de donnée trop longue

Filtrer et paginer

Si votre API à plus de 100 posts à envoyer à votre frontend cela peut demander beaucoup de ressources pour votre serveur et votre client. Ou peut-être que ce client ne veut pas afficher tous les posts d’un seul coup, ou veut que les posts d’une certaine catégorie publié avant une certaines dates.

Pour cela, voyons comme nous pouvons mettre en place des filtres et une pagination avec Laravel:

public function index(Request $request)
{
    $query = Post::with(['category', 'author']);
    
    if ($request->has('category')) {
        $query->whereHas('category', function ($q) use ($request) {
            $q->where('slug', $request->category);
        });
    }
    
    if ($request->has('author')) {
        $query->where('user_id', $request->author);
    }
    
    if ($request->boolean('published')) {
        $query->whereNotNull('published_at');
    }
    
    return PostResource::collection(
        $query->latest()->paginate(15)
    );
}

Ici les filtres modifient la requête pour récupérer nos Models.

La fonction paginate nous donnes un tableau avec tous les posts et des meta (total de post, page actuelle, nombre de post retourné) et les liens pour aller aux précédente et suivante.

3. Halt qui va là ?!

Maintenant que notre API fonctionne, voyons comme la protéger afin de controller qui et comment elle est utilisé.

On veut que les utilisateurs puissent: créer un compte, se connecter, gérer leurs données. Il ne faut pas qu’un utilisateur puisse gérer les données d’un autre.

Laravel Sanctum à la rescousse

Sanctum permet de créer un système d’authentification à partir de Token (souvent utiliser pour les applications Web et Mobile).

  1. Installer et publier le package
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
  1. Ajouter le middleware
'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],
  1. Ajouter le Trait HasApiToken au Model de l’utilisateur
use Laravel\Sanctum\HasApiTokens;
 
class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
    // ...
}

Inscription et connexion

Pour simplifier les exemples, je mettrais la validation dans le Controller.

Voici une route pour inscrire un utilisateur:

public function register(Request $request)
{
	// On valide les données de l'utilisateur
    $request->validate([
        'name' => ['required', 'string', 'max:255'],
        'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
        'password' => ['required', 'string', 'min:8', 'confirmed'],
    ]);
    
    // On créer l'utilisateur
    $user = User::create([
        'name' => $request->name,
        'email' => $request->email,
        'password' => Hash::make($request->password),
    ]);
    
    // On génère un token pour l'utilisateur
    $token = $user->createToken('auth-token')->plainTextToken;
    
    return response()->json([
        'user' => new UserResource($user),
        'token' => $token,
    ], 201);
}

Et voici la route pour la connexion:

public function login(Request $request)
{
	// On valide les informations
    $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required'],
    ]);
    
    // On valide le combo nom/mot de passe
    if (!Auth::attempt($request->only('email', 'password'))) {
        return response()->json([
            'message' => 'Invalid credentials'
        ], 401);
    }
    
    // On récupère l'utilisateur et on génère un token
    $user = User::where('email', $request->email)->firstOrFail();
    $token = $user->createToken('auth-token')->plainTextToken;
    
    return response()->json([
        'user' => new UserResource($user),
        'token' => $token,
    ]);
}

Pour authentifier les requêtes suivantes devront avoir le header suivant:

Authorization: Bearer {token}

Protéger des routes

Pour protéger certaines routes, vous les englobez par le middleware auth:sanctum dans votre api.php:

Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('bookmarks', BookmarkController::class);
});

Toutes requêtes avec un token non-valide ou manquant sera retourné avec une réponse 401.

Vos papiers s’il vous plait

Dans notre exemple, chaque utilisateur possède plusieurs marque-pages. Cela se traduit par la relation bookmarks dans le Model User.

Voici les routes pour voir et ajouter les marque-pages pour un utilisateur précis:

public function index(Request $request)
{
    return BookmarkResource::collection(
        $request->user()->bookmarks()->latest()->paginate()
    );
}
 
public function store(Request $request)
{
    $bookmark = $request->user()->bookmarks()->create(
        $request->validated()
    );
    
    return new BookmarkResource($bookmark);
}

Nous utilisons la méthode $request->user pour récupérer l’utilisateur authentifié, puis nous récupérons les marque-pages par la relation.

Pour la modification et la suppression, nous pouvons utiliser les Policies:

public function update(User $user, Bookmark $bookmark): bool
{
    return $user->id === $bookmark->user_id;
}
 
public function delete(User $user, Bookmark $bookmark): bool
{
    return $user->id === $bookmark->user_id;
}

Ici nous définissons les règles pour chaque actions possibles sur le Model Bookmark.

Et dans notre Controller:

public function update(Request $request, Bookmark $bookmark)
{
    $this->authorize('update', $bookmark);
    
    $bookmark->update($request->validated());
    
    return new BookmarkResource($bookmark);
}
 
public function delete(Request $request, Bookmark $bookmark)
{
    $this->authorize('deelte', $bookmark);
    
    $bookmark->delete();
    
    return new JsonResponse('OK');
}

Quand nous appelons $this->authorize('update', $bookmark);, Laravel appelle la méthode update de la Policy avec le marque-page et l’utilisateur actuel. Si la Policy retourne faux, Laravel retourne une 403.

4. À la recherche de la recette parfaite

Nous avons une base de données de recette de cuisine. Nous souhaitons que l’utilisateur puisse chercher les recettes par ingrédient, cuisine et restrictions alimentaire.

Si on reprend les exemples précédents, nous pourrions mettre tous nos filtres dans le controller. Cependant, cela le ferai grossir, et le rendrai moins maintenable.

La base de donnée

Schema::create('recipes', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('title');
    $table->text('description');
    $table->text('instructions');
    $table->string('cuisine')->nullable();
    $table->integer('prep_time');
    $table->integer('cook_time');
    $table->integer('servings');
    $table->json('dietary_info')->nullable(); // ['vegetarian', 'gluten-free']
    $table->timestamps();
});
 
Schema::create('ingredients', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
});
 
Schema::create('ingredient_recipe', function (Blueprint $table) {
    $table->foreignId('recipe_id')->constrained()->onDelete('cascade');
    $table->foreignId('ingredient_id')->constrained()->onDelete('cascade');
    $table->string('quantity');
    $table->string('unit')->nullable();
});

Nettoyons le Controller avec des QueryScope

Prenons par exemple ce Controller:

public function index(Request $request)
{
    $query = Recipe::query();
    
    if ($request->has('cuisine')) {
        $query->where('cuisine', $request->cuisine);
    }
    
    if ($request->has('max_time')) {
        $query->where(DB::raw('prep_time + cook_time'), '<=', $request->max_time);
    }
    
    if ($request->has('dietary')) {
        $dietary = json_decode($request->dietary);
        $query->where(function ($q) use ($dietary) {
            foreach ($dietary as $diet) {
                $q->orWhereJsonContains('dietary_info', $diet);
            }
        });
    }
    
    if ($request->has('search')) {
        $search = $request->search;
        $query->where(function ($q) use ($search) {
            $q->where('title', 'like', "%{$search}%")
              ->orWhere('description', 'like', "%{$search}%");
        });
    }
    
    return RecipeResource::collection($query->paginate());
}

Il serai plus interessant de mettre cette logique dans une autre classe comme … le Model de recette !

class Recipe extends Model
{
    public function scopeCuisine($query, $cuisine)
    {
        return $query->where('cuisine', $cuisine);
    }
    
    public function scopeMaxTotalTime($query, $minutes)
    {
        return $query->whereRaw('(prep_time + cook_time) <= ?', [$minutes]);
    }
    
    public function scopeDietary($query, array $requirements)
    {
        foreach ($requirements as $requirement) {
            $query->whereJsonContains('dietary_info', $requirement);
        }
        
        return $query;
    }
    
    public function scopeSearch($query, $term)
    {
        return $query->where(function ($q) use ($term) {
            $q->where('title', 'like', "%{$term}%")
              ->orWhere('description', 'like', "%{$term}%");
        });
    }
    
    public function scopeWithIngredient($query, $ingredientId)
    {
        return $query->whereHas('ingredients', function ($q) use ($ingredientId) {
            $q->where('ingredient_id', $ingredientId);
        });
    }
}

Maintenant dans notre Controller, on peut appeler ces QueryScope à partir du Model:

public function index(Request $request)
{
    $query = Recipe::with(['user', 'ingredients']);
    
    if ($request->filled('cuisine')) {
        $query->cuisine($request->cuisine);
    }
    
    if ($request->filled('max_time')) {
        $query->maxTotalTime($request->max_time);
    }
    
    if ($request->filled('dietary')) {
        $query->dietary($request->dietary);
    }
    
    if ($request->filled('search')) {
        $query->search($request->search);
    }
    
    if ($request->filled('ingredient')) {
        $query->withIngredient($request->ingredient);
    }
    
    return RecipeResource::collection(
        $query->latest()->paginate()
    );
}

On peut donc réutiliser, tester et maintenir facilement ces filtres !

Rechercher avec Scout

Vous avez peut-être remarqué l’utilisation de l’opérateur SQL LIKE dans la recherche. Cela fonctionne bien avec de petite base de données mais quand celle-ci grossi, les performances vont vite être dégradées.

Pour nous aider, Laravel Scout permet de se connecter à des services comme Algolia, Meilisearch et Typesense (voir Typesense - Alternative à Algolia. Pour cela:

  1. Installer le composant et publier la configuration
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
  1. Rendre un Model ‘recherchable’
use Laravel\Scout\Searchable;
 
class Recipe extends Model
{
    use Searchable;
    
    public function toSearchableArray()
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'cuisine' => $this->cuisine,
            'ingredients' => $this->ingredients->pluck('name')->toArray(),
        ];
    }
}

Ici on défini via le Trait que le Model est ‘recherchable’ et implémentons la méthode toSearchableArray pour définir les champs à indexer.

La recherche devient plus simple:

$recipes = Recipe::search($request->search)
    ->query(fn ($query) => $query->with('ingredients'))
    ->paginate();

Laravel Scout va faire le plus dur pour nous et synchroniser le service externe avec notre base de donnée.

5. Retourner des données complexes

Généralement, une API ne retourne pas de données brutes. Elle va souvent effecteur des calculs ou grouper des données.

Dans cet exemple, nous allons voir comment un utilisateur peut tracker ses dépenses. Chaque dépense à un montant une catégorie, une date et des notes optionnelles. Notre API ne doit pas juste nous donner une liste de dépense, mais aussi des données complémentaires comme:

  • les totaux par catégorie
  • le résumé par mois
  • la différence entre années

La base de donnée

Schema::create('expenses', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->foreignId('category_id')->constrained();
    $table->decimal('amount', 10, 2);
    $table->date('date');
    $table->string('description')->nullable();
    $table->timestamps();
});
 
Schema::create('categories', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('name');
    $table->string('color')->nullable();
    $table->timestamps();
});

Simple agrégation

public function categoryTotals(Request $request)
{
    $totals = $request->user()
        ->expenses()
        ->selectRaw('category_id, SUM(amount) as total')
        ->groupBy('category_id')
        ->with('category:id,name,color')
        ->get();
    
    return response()->json([
        'data' => $totals->map(fn ($item) => [
            'category' => [
                'id' => $item->category->id,
                'name' => $item->category->name,
                'color' => $item->category->color,
            ],
            'total' => (float) $item->total,
        ])
    ]);
}

Nous utilisons les méthodes selectRaw et groupBy. Ce sont des méthodes d’agrégations SQL, elle nous permette de créer des requêtes à la base de donnée, tous en utilisant la puissance du moteur de la base. Cependant, l’utilisation de ces méthodes ne retourne plus le Model mais que les données souhaitées.

Filtre par rangée de date

public function summary(Request $request)
{
    $request->validate([
        'start_date' => ['required', 'date'],
        'end_date' => ['required', 'date', 'after_or_equal:start_date'],
    ]);
    
    $expenses = $request->user()
        ->expenses()
        ->whereBetween('date', [$request->start_date, $request->end_date])
        ->with('category')
        ->get();
    
    return response()->json([
        'period' => [
            'start' => $request->start_date,
            'end' => $request->end_date,
        ],
        'total_expenses' => $expenses->sum('amount'),
        'count' => $expenses->count(),
        'average' => $expenses->avg('amount'),
        'by_category' => $expenses->groupBy('category_id')->map(function ($items) {
            return [
                'category' => $items->first()->category->name,
                'total' => $items->sum('amount'),
                'count' => $items->count(),
            ];
        })->values(),
    ]);
}

Ici, nous utilisons en plus de l’agrégation en base de donnée, nous utilisons les Collections de Laravel pour agréger des données supplémentaires.

Pour une utilisations avec peu de données, cette méthode est parfaite. Cependant, lorsque le nombre de donnée augmente, je conseil de reporter le calcul vers le moteur de la base de donnée.

Reporting mensuel

public function monthlyReport(Request $request, int $year)
{
    $expenses = $request->user()
        ->expenses()
        ->whereYear('date', $year)
        ->selectRaw('MONTH(date) as month, SUM(amount) as total, COUNT(*) as count')
        ->groupBy('month')
        ->orderBy('month')
        ->get();
    
    // On rempli les mois manquants par 0
    $months = collect(range(1, 12))->map(function ($month) use ($expenses) {
        $data = $expenses->firstWhere('month', $month);
        
        return [
            'month' => $month,
            'month_name' => Carbon::create()->month($month)->format('F'),
            'total' => $data ? (float) $data->total : 0,
            'count' => $data ? $data->count : 0,
        ];
    });
    
    return response()->json([
        'year' => $year,
        'months' => $months,
        'yearly_total' => $months->sum('total'),
    ]);
}

Si on ne rempli pas les mois sans valeur avec des valeurs par défauts, on risque de casser certaines librairie frontend.

6. Ticket s’il vous plait !

Pour l’instant, nous avons uniquement vu des relations OneToMany (et inversement). Cet avec les relations ManyToMany qu’Eloquent devient avantageux, mais aussi plus complexe.

Dans cet exemple, nous avons des utilisateurs qui sont attendus à un événement. Un utilisateur peut être invité à plusieurs événements. La relation entre l’utilisateur et un événement à aussi le status (viens, peut-être, ne vient pas). Un événement à une limite de personne pouvant être présent.

La base de donnée

Schema::create('events', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('title');
    $table->text('description');
    $table->string('location');
    $table->dateTime('starts_at');
    $table->dateTime('ends_at');
    $table->integer('capacity')->nullable();
    $table->timestamps();
});
 
Schema::create('event_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('event_id')->constrained()->onDelete('cascade');
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->enum('status', ['going', 'maybe', 'not_going']);
    $table->text('note')->nullable();
    $table->timestamps();
    
    $table->unique(['event_id', 'user_id']);
});

Les relations

class Event extends Model
{
    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
    
    public function attendees(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->withPivot('status', 'note')
            ->withTimestamps();
    }
    
    public function confirmed(): BelongsToMany
    {
        return $this->attendees()->wherePivot('status', 'going');
    }
    
    public function maybe(): BelongsToMany
    {
        return $this->attendees()->wherePivot('status', 'maybe');
    }
}
 
class User extends Model
{
    public function events(): BelongsToMany
    {
        return $this->belongsToMany(Event::class)
            ->withPivot('status', 'note')
            ->withTimestamps();
    }
    
    public function createdEvents(): HasMany
    {
        return $this->hasMany(Event::class);
    }
}

Pour avoir le status de la réservation, nous utilisons la méthode withPivot qui nous permet de récupérer des données dans la base de pivot.

La logique de réservation

public function rsvp(Request $request, Event $event)
{
    $request->validate([
        'status' => ['required', 'in:going,maybe,not_going'],
        'note' => ['nullable', 'string', 'max:500'],
    ]);
    
    // On vérifie que la limite de personne n'est pas dépassé
    if ($request->status === 'going' && $event->capacity) {
        $confirmed = $event->confirmed()->count();
        
        if ($confirmed >= $event->capacity) {
            return response()->json([
                'message' => 'This event is at capacity'
            ], 422);
        }
    }
    
    // Ajoute ou modifie la réservation
    $event->attendees()->syncWithoutDetaching([
        $request->user()->id => [
            'status' => $request->status,
            'note' => $request->note,
        ]
    ]);
    
    return response()->json([
        'message' => 'RSVP updated successfully',
        'status' => $request->status,
    ]);
}

La méthode syncWithoutDetaching permet de mettre à jour la table de pivot si la réservation est déjà présente, sinon il la créer.

Récupérer la liste de participant

Quand nous récupérons les participants d’un événement, nous avons aussi accès aux données de pivot:

$event = Event::with('attendees')->find($id);
 
foreach ($event->attendees as $attendee) {
    echo $attendee->pivot->status;
    echo $attendee->pivot->created_at;
}

Nous pouvons donc l’exposer dans une Resource:

class EventResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'starts_at' => $this->starts_at->toISOString(),
            'capacity' => $this->capacity,
            'attendee_count' => $this->confirmed()->count(),
            'spots_remaining' => $this->capacity 
                ? max(0, $this->capacity - $this->confirmed()->count())
                : null,
            'attendees' => $this->whenLoaded('attendees', function () {
                return $this->attendees->map(fn ($user) => [
                    'id' => $user->id,
                    'name' => $user->name,
                    'status' => $user->pivot->status,
                    'rsvp_at' => $user->pivot->created_at->toISOString(),
                ]);
            }),
        ];
    }
}

7. Une API multi-tenant

Une application SaaS à souvent besoin de séparer les données de différentes équipes/entrerpises/espaces de travail, on appel cela le multi-tenant.

Dans notre cas, les données appartiennent à une équipe. Un utilisateur peut faire partie de plusieurs équipes. Chaque table qui contient les données d’utilisateur doit avoir un champ team_id. Chaque requêtes à besoin d’être ‘scoper’ par l’équipe actuel. L’utilisateur pourra changer d’équipe afin de segmenter les données.

Schema::create('teams', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->foreignId('owner_id')->constrained('users');
    $table->timestamps();
});
 
Schema::create('team_user', function (Blueprint $table) {
    $table->foreignId('team_id')->constrained()->onDelete('cascade');
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->string('role')->default('member');
    $table->timestamps();
    
    $table->primary(['team_id', 'user_id']);
});
 
Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->constrained()->onDelete('cascade');
    $table->string('name');
    $table->text('description')->nullable();
    $table->timestamps();
});

Les scopes globaux

Nous avions vu que l’on pouvais créer des scopes dans un Model. Si nous devions préciser dans chaque requête Eloquent de prendre que les données avec un team_id, cela prendrai beaucoup de temps et rendrait moins maintenable le code.

Pour nous aider, Laravel à mis en place les GlobalScopes:

namespace App\Models\Scopes;
 
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
 
class TeamScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if ($teamId = $this->getCurrentTeamId()) {
            $builder->where("{$model->getTable()}.team_id", $teamId);
        }
    }
    
    protected function getCurrentTeamId(): ?int
    {
        return auth()->user()?->currentTeam?->id;
    }
}

Puis nous appliquons le scope à notre Model:

class Project extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TeamScope);
    }
}

Maintenant tous requêtes Eloquent sur le Model Project, auront le scope de sélection de l’équipe. Cela évite les erreurs et les oublis d’ajouter le scope dans l’application.

Changer d’équipe

Nous stockons l’équipe actuelle sélectionné par l’utilisateur en base avec l’utilisateur. Pour cela, nous ajoutons quelques méthodes à notre Model User:

class User extends Model
{
    public function currentTeam(): BelongsTo
    {
        return $this->belongsTo(Team::class, 'current_team_id');
    }
    
    public function teams(): BelongsToMany
    {
        return $this->belongsToMany(Team::class)
            ->withPivot('role')
            ->withTimestamps();
    }
    
    public function switchTeam(Team $team): void
    {
        if (!$this->teams->contains($team->id)) {
            throw new \Exception('User does not belong to this team');
        }
        
        $this->current_team_id = $team->id;
        $this->save();
    }
}

Et dans un Controller:

public function switch(Request $request, Team $team)
{
    $request->user()->switchTeam($team);
    
    return response()->json([
        'message' => 'Switched to team: ' . $team->name,
        'current_team' => new TeamResource($team),
    ]);
}

8. Éviter les abus

Pour finir, nous allons voir comment éviter que des utilisateurs authentifiés ou non abusent de notre API, on appel cela: le rate-limiting.

La base de donnée

Schema::create('api_keys', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('name');
    $table->string('key')->unique();
    $table->string('tier')->default('free'); // free, pro, enterprise
    $table->timestamp('last_used_at')->nullable();
    $table->boolean('active')->default(true);
    $table->timestamps();
});
 
Schema::create('api_key_usages', function (Blueprint $table) {
    $table->id();
    $table->foreignId('api_key_id')->constrained();
    $table->string('endpoint');
    $table->timestamp('requested_at');
    $table->integer('response_status');
    $table->integer('response_time_ms');
    
    $table->index(['api_key_id', 'requested_at']);
});

Voici la route pour générer un clé d’api à un utilisateur:

public function create(Request $request)
{
    $request->validate([
        'name' => ['required', 'string', 'max:255'],
    ]);
    
    $apiKey = $request->user()->apiKeys()->create([
        'name' => $request->name,
        'key' => 'sk_' . bin2hex(random_bytes(32)),
        'tier' => 'free',
    ]);
    
    return response()->json([
        'key' => $apiKey->key,
        'name' => $apiKey->name,
        'message' => 'Store this key securely. It will not be shown again.',
    ], 201);
}

Rate-limiting depuis un middleware

Laravel à un système de rate-limiting intégré dans le framework.

Pour l’utiliser, nous avons juste à ajouter ce bloc de code dans le fichier RouteServiceProvider.php:

RateLimiter::for('api', function (Request $request) {
    $apiKey = ApiKey::where('key', $request->bearerToken())->first();
    
    if (!$apiKey) {
        return Limit::perMinute(10);
    }
    
    return match ($apiKey->tier) {
        'free' => Limit::perMinute(60)->by($apiKey->id),
        'pro' => Limit::perMinute(300)->by($apiKey->id),
        'enterprise' => Limit::none(),
    };
});

Pour l’appliquer à des routes, il nous suffit d’englober les routes par le middleware:

Route::middleware('throttle:api')->group(function () {
    // Route rate-limité
});

Laravel renverra automatiquement une réponse 429 quand une limite sera dépassé et inclura le header: X-RateLimit-*.

Bonus

Middleware pour ajouter la clé d’API à la requête

namespace App\Http\Middleware;
 
use App\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
 
class AuthenticateApiKey
{
    public function handle(Request $request, Closure $next)
    {
        $key = $request->bearerToken();
        
        if (!$key) {
            return response()->json([
                'error' => 'API key required'
            ], 401);
        }
        
        $apiKey = ApiKey::where('key', $key)
            ->where('active', true)
            ->first();
        
        if (!$apiKey) {
            return response()->json([
                'error' => 'Invalid API key'
            ], 401);
        }
        
        $apiKey->update(['last_used_at' => now()]);
        $request->merge(['api_key' => $apiKey]);
        
        return $next($request);
    }
}

Tracking de l’utilisation

namespace App\Http\Middleware;
 
use App\Models\ApiKeyUsage;
use Closure;
use Illuminate\Http\Request;
 
class TrackApiUsage
{
    public function handle(Request $request, Closure $next)
    {
        $startTime = microtime(true);
        $response = $next($request);
        $duration = (microtime(true) - $startTime) * 1000;
        
        if ($apiKey = $request->get('api_key')) {
            ApiKeyUsage::create([
                'api_key_id' => $apiKey->id,
                'endpoint' => $request->path(),
                'requested_at' => now(),
                'response_status' => $response->status(),
                'response_time_ms' => round($duration),
            ]);
        }
        
        return $response;
    }
}

Avec ces données vous pouvez:

  • Afficher l’utilisation par un utilisateur
  • Facturer à l’utilisation
  • Identifier une route longue
  • Détecter les abus

Récupérer l’utilisation d’une clé

public function usage(Request $request)
{
    $apiKey = $request->get('api_key');
    
    $today = $apiKey->usages()
        ->whereDate('requested_at', today())
        ->count();
    
    $thisMonth = $apiKey->usages()
        ->whereMonth('requested_at', now()->month)
        ->count();
    
    $byEndpoint = $apiKey->usages()
        ->whereMonth('requested_at', now()->month)
        ->groupBy('endpoint')
        ->selectRaw('endpoint, COUNT(*) as count')
        ->orderByDesc('count')
        ->limit(10)
        ->get();
    
    return response()->json([
        'today' => $today,
        'this_month' => $thisMonth,
        'limit' => $this->getLimitForTier($apiKey->tier),
        'top_endpoints' => $byEndpoint,
    ]);
}

Que faire après ?

Ecrivez des tests

Que vous soyez adepte du TDD ou bien un développeur consciencieux, écrivez des tests pour vos routes !

test('authenticated users can create posts', function () {
    $user = User::factory()->create();
    
    $response = $this->actingAs($user)->postJson('/api/posts', [
        'title' => 'Test Post',
        'content' => 'Content here',
    ]);
    
    $response->assertCreated()
        ->assertJsonStructure(['data' => ['id', 'title', 'content']]);
    
    $this->assertDatabaseHas('posts', [
        'title' => 'Test Post',
        'user_id' => $user->id,
    ]);
});

Ici nous utilisons Pest

Tester les API est souvent plus simple que tester des applications complètes, car il suffit de vérifier les réponses JSON. Rédigez les tests au fur et à mesure du développement des fonctionnalités, et non après.

Documenter votre code

Pour documenter mon code Laravel, j’aime bien utiliser le package Scribe:

composer require --dev knuckleswtf/scribe
php artisan vendor:publish --tag=scribe-config
php artisan scribe:generate

Scribe lit nos routes, Controller, FormRequest pour générer une documentation automatiquement. Pour aller plus loin, vous pouvez annoter votre code:

/**
 * Create a new post
 * 
 * Creates a new post for the authenticated user.
 * 
 * @bodyParam title string required The post title. Example: My First Post
 * @bodyParam content string required The post content. Example: This is the content.
 * 
 * @response 201 {"data":{"id":1,"title":"My First Post"}}
 */
public function store(StorePostRequest $request)
{
    // ...
}

Versionner votre API

Si vous prévoyez de faire évoluer votre API sans casser les anciens contrats d’API, vous pouvez versionner votre api:

Route::prefix('v1')->group(function () {
    // Version 1 
});
 
Route::prefix('v2')->group(function () {
    // Version 2 
});

Gérer les erreurs

Un client ne devrai jamais avoir une page d’erreur HTML ou bien une stack trace en production.

Pour cela, voici une classe permettant de formatter les réponses:

namespace App\Exceptions;
 
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
 
class Handler extends ExceptionHandler
{
    public function render($request, Throwable $e)
    {
        if ($request->expectsJson()) {
            if ($e instanceof NotFoundHttpException) {
                return response()->json([
                    'message' => 'Resource not found'
                ], 404);
            }
            
            if ($e instanceof ValidationException) {
                return response()->json([
                    'message' => 'Validation failed',
                    'errors' => $e->errors()
                ], 422);
            }
            
            return response()->json([
                'message' => $e->getMessage()
            ], 500);
        }
        
        return parent::render($request, $e);
    }
}

Monitorer votre API

Il est essentiel de savoir quand votre API est défaillante. Utilisez des outils comme Sentry ou Bugsnag pour le suivi des erreurs. En développement, utilisez Laravel Telescope pour visualiser chaque requête, tâche et événement.

En plus de ça, créer une route de santé de votre application:

Route::get('/health', function () {
    return response()->json([
        'status' => 'healthy',
        'timestamp' => now()->toISOString(),
    ]);
});

Vos outils de surveillance peuvent utiliser cette route pour vérifier que votre API répond bien.

Continuer à apprendre

Voici la vérité : vous n’apprenez pas vraiment ces concepts en les lisant. Vous les apprenez en développant des projets, en faisant des erreurs, en les refactorisant et en recommençant.

Choisissez un projet dans cette liste. Choisissez-en un qui vous semble intéressant. Aller jusqu’au bout : tests, documentation, gestion des erreurs, tout le travail nécessaire. Déployez-le quelque part. Laissez-le fonctionner pendant une semaine. Puis revenez-y et refactorisez-le en utilisant ce que vous avez appris.

Ensuite, choisissez le projet suivant.

J’utilise aussi ces projets pour faire des montées de versio nde Laravel, tester de nouvelles fonctionnalités, etc

Ces projets vous donnent la structure. Mais l’apprentissage ? Il ne se fait que lorsque vous écrivez réellement du code.

Si vous avez des questions, je reste disponible par mail ou via le formulaire de contact sur la page d’accueil du site !