backend / php-laravel
Eloquent : relations entre modèles
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,detachetsync - 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.
| Relation | Sens | Table pivot ? | Exemple |
|---|---|---|---|
belongsTo | N vers 1 | Non | Un produit appartient à une catégorie |
hasMany | 1 vers N | Non | Une catégorie a plusieurs produits |
belongsToMany | N vers N | Oui | Un produit a plusieurs tags |
hasManyThrough | 1 vers N via un intermédiaire | Non | Les 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
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.withCountcompte les relations sans les charger entièrement (agrégation SQL).sync()remplace tout le pivot,attach()/detach()ajoutent/retirent ponctuellement.whereHasfiltre les modèles parents selon l'existence de lignes liées (sous-requête EXISTS).
Exercices pratiques
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.