Retour au cours

backend / php-laravel

Eloquent : relations entre modèles

Leçon 111 exercice

Explication

Ce que vous allez apprendre

  • Déclarer les quatre types de relations Eloquent les plus courants (belongsTo, hasMany, belongsToMany, hasManyThrough)
  • Comprendre précisément ce qu'est le problème N+1 et pourquoi il dégrade silencieusement les performances
  • Résoudre le N+1 avec l'eager loading (with()), y compris imbriqué sur plusieurs niveaux
  • Manipuler une relation many-to-many avec attach, detach et sync
  • Filtrer des modèles selon l'existence de données liées avec whereHas

Dans quel contexte ?

Une page d'administration doit afficher 50 commandes, chacune avec le nom de son client et la liste de ses produits. Sans précaution, cette page déclenche une requête SQL pour chaque commande en plus de la requête initiale — 101 requêtes au lieu d'une poignée, un problème qui passe souvent inaperçu en développement (peu de données) mais qui devient critique en production dès que le volume augmente.

D'abord, comment Eloquent représente une relation entre deux tables

Chaque type de relation correspond à une structure SQL précise. belongsTo (un produit appartient à une catégorie) correspond à une clé étrangère sur la table courante. hasMany (une catégorie a plusieurs produits) est l'inverse exact de belongsTo. belongsToMany gère une relation many-to-many via une table pivot intermédiaire.

RelationSensTable pivot ?Exemple
belongsToN vers 1NonUn produit appartient à une catégorie
hasMany1 vers NNonUne catégorie a plusieurs produits
belongsToManyN vers NOuiUn produit a plusieurs tags
hasManyThrough1 vers N via un intermédiaireNonLes avis d'un client via ses commandes

Prérequis

Il faut bien maîtriser les bases d'Eloquent (leçon précédente) : les relations s'appuient directement sur le query builder déjà vu.

Ensuite, le problème de performance le plus fréquent en Eloquent

Sans précaution, accéder à $commande->client dans une boucle sur 50 commandes déclenche 50 requêtes SQL séparées, une par commande — en plus de la requête initiale qui a chargé les commandes. C'est le fameux problème N+1 : N relations chargées une par une au lieu d'une seule requête groupée.

Piège fréquent

Le problème N+1 ne se voit presque jamais en développement local, où les tables contiennent peu de lignes et où tout semble instantané. Il explose en production sur de gros volumes, souvent découvert tardivement via un monitoring de lenteur. Prendre l'habitude de l'eager loading dès le développement évite cette mauvaise surprise.

Il reste une solution simple : charger les relations à l'avance

Commande::with(['client', 'produits'])->get() charge toutes les commandes ET leurs relations en un nombre fixe de requêtes (généralement 2 ou 3, peu importe le nombre de commandes), au lieu d'une requête par relation par ligne. On peut même imbriquer l'eager loading sur plusieurs niveaux avec la notation par points : Categorie::with('produits.avis').

Astuce

withCount('produits') compte les éléments liés via une sous-requête SQL COUNT, sans jamais charger les lignes elles-mêmes en mémoire. Idéal pour afficher "12 produits" sans avoir besoin des 12 produits complets.

Maintenant, manipuler une relation many-to-many au quotidien

Une relation belongsToMany (comme les tags d'un produit) se manipule avec trois méthodes complémentaires : attach() ajoute des associations sans toucher aux existantes, detach() en retire une précise, et sync() remplace intégralement le contenu du pivot par la liste fournie. Confondre attach et sync est une source fréquente de bugs : attach peut créer des doublons si appelé plusieurs fois avec les mêmes valeurs.

Maintenant que tu sais modéliser et charger efficacement des relations entre données, il devient essentiel de savoir comment ce schéma de base de données lui-même est créé et versionné : direction la prochaine leçon, sur les migrations et les seeders.

Commandes & code

Eloquent : relations entre modèles

php
<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, HasMany, BelongsToMany, HasManyThrough};

class Categorie extends Model {
    public function produits(): HasMany {
        return $this->hasMany(Produit::class);
    }
}

class Produit extends Model {
    // Relation N-1 : un produit appartient à une catégorie
    public function categorie(): BelongsTo {
        return $this->belongsTo(Categorie::class);
    }

    // Relation N-N avec table pivot explicite et colonnes supplémentaires
    public function tags(): BelongsToMany {
        return $this->belongsToMany(Tag::class, 'produit_tag')
            ->withPivot('ajoute_le')
            ->withTimestamps();
    }

    public function avis(): HasMany {
        return $this->hasMany(Avis::class);
    }
}

class Commande extends Model {
    // Relation N-N avec pivot riche (quantité, prix au moment de la commande)
    public function produits(): BelongsToMany {
        return $this->belongsToMany(Produit::class, 'lignes_commande')
            ->withPivot('quantite', 'prix_unitaire')
            ->withTimestamps();
    }

    public function client(): BelongsTo {
        return $this->belongsTo(Client::class);
    }
}

class Client extends Model {
    public function commandes(): HasMany {
        return $this->hasMany(Commande::class);
    }

    // hasManyThrough : les avis d'un client à travers ses commandes et produits
    public function avisDonnes(): HasManyThrough {
        return $this->hasManyThrough(Avis::class, Commande::class);
    }
}

// Eager loading : évite le problème N+1 (une requête au lieu de N)
$commandes = Commande::with(['client', 'produits' => function ($query) {
    $query->where('est_disponible', true);
}])->get();

// Eager loading imbriqué (dot notation)
$categories = Categorie::with('produits.avis')->get();

// Comptage sans charger les relations (withCount génère une sous-requête COUNT)
$categories = Categorie::withCount('produits')->get();
foreach ($categories as $c) {
    echo "{$c->nom}: {$c->produits_count} produits
";
}

// Lazy eager loading : charger une relation après coup sur une collection déjà récupérée
$produits = Produit::all();
$produits->load('categorie');

// Attacher / détacher dans une table pivot N-N
$produit = Produit::find(1);
$produit->tags()->attach([1, 2, 3]);               // ajoute des associations
$produit->tags()->sync([2, 3]);                     // remplace par exactement [2, 3]
$produit->tags()->detach(1);                        // retire l'association au tag 1

// Contraintes sur les relations (whereHas) : filtrer par existence liée
$categoriesAvecStock = Categorie::whereHas('produits', function ($q) {
    $q->where('stock', '>', 0);
})->get();

Résumé

  • with() résout le problème N+1 en pré-chargeant les relations en une requête groupée.
  • withCount compte les relations sans les charger entièrement (agrégation SQL).
  • sync() remplace tout le pivot, attach()/detach() ajoutent/retirent ponctuellement.
  • whereHas filtre les modèles parents selon l'existence de lignes liées (sous-requête EXISTS).

Exercices pratiques

1 disponible
1

Mission : eteindre un incendie de performance N+1

Objectif : Diagnostiquer et corriger un probleme N+1 sur une page d'administration, puis manipuler correctement une relation many-to-many.

Contexte

La page d'administration des commandes est devenue tres lente en production, alors qu'elle etait rapide en developpement local. Le controleur fait Commande::all() puis, dans la vue Blade, accede a $commande->client->nom et $commande->produits pour chacune des 50 commandes affichees. Par ailleurs, un bouton "retirer tous les tags sauf ceux selectionnes" utilise actuellement $produit->tags()->attach($idsSelectionnes), ce qui accumule des doublons a chaque clic au lieu de remplacer la selection.

Résoudre l’exercice →