📍 Guide de référence de cette série : Laravel 11 et PHP 8.4 : installer l’environnement et maîtriser la nouvelle architecture
Eloquent est l’ORM de Laravel — il fait le pont entre vos objets PHP et vos tables SQL. Dans Laravel 11, plusieurs fonctionnalités ont été consolidées ou simplifiées : les attributs #[ScopedBy] pour les scopes globaux, le trait HasUuids pour les clés primaires UUID, la méthode withAttributes() dans les scopes locaux, et la commande php artisan model:show pour l’introspection. Ce tutoriel couvre les relations, les scopes, le casting et les patterns avancés d’interrogation — avec un focus sur les fonctionnalités spécifiques à Laravel 11 qui améliorent l’expressivité et la sécurité de vos modèles.
Prérequis
- Projet Laravel 11 configuré — voir le guide d’installation
- Base de données connectée et migrations de base exécutées
- Notions de SQL (SELECT, JOIN, clés étrangères)
- Temps estimé : 60 minutes
Étape 1 — Créer un modèle Eloquent avec toutes ses dépendances
Dans Laravel 11, la commande make:model a été enrichie de nouveaux flags qui génèrent l’ensemble des fichiers associés à un modèle en une seule passe. C’est particulièrement utile lors de la construction d’une nouvelle fonctionnalité : vous obtenez le modèle, la migration, la factory et le seeder sans avoir à les créer séparément et à les lier manuellement.
# Génère Article + migration + factory + seeder + policy + contrôleur
php artisan make:model Article -a
# Inspecter le modèle une fois créé (commande disponible depuis Laravel 11)
php artisan model:show Article
La commande model:show est l’une des nouveautés les plus pratiques de Laravel 11 : elle affiche dans le terminal les colonnes de la table, leurs types SQL, les attributs $fillable/$guarded, les casts déclarés, les relations définies et les scopes. C’est un outil de débogage et d’audit architectural qui remplace avantageusement l’ouverture manuelle du fichier de migration et du modèle. Le signal de réussite : vous voyez une table ASCII détaillant toutes les colonnes de articles avec leurs types (bigint, varchar, text, etc.).
Étape 2 — Configurer les attributs fillable, les casts et les UUID
La protection par masse d’affectation est l’une des premières lignes de défense contre les vulnérabilités d’injection de paramètres (Mass Assignment). Par défaut, tous les attributs d’un modèle Eloquent sont protégés — vous devez explicitement déclarer quels champs peuvent être remplis via create() ou fill(). Dans Laravel 11, le trait HasUuids permet d’utiliser des UUID comme clés primaires sans configuration externe :
<?php
// app/Models/Article.php
namespace AppModels;
use IlluminateDatabaseEloquentConcernsHasUuids;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsBelongsTo;
use IlluminateDatabaseEloquentRelationsHasMany;
use IlluminateDatabaseEloquentSoftDeletes;
class Article extends Model
{
use HasUuids, SoftDeletes;
protected $fillable = [
'title', 'content', 'slug', 'status', 'published_at', 'user_id',
];
protected $casts = [
'published_at' => 'datetime:Y-m-d',
'status' => ArticleStatus::class, // Enum PHP 8.1+ natif
'metadata' => 'array', // JSON sérialisé/désérialisé automatiquement
];
protected $hidden = ['deleted_at']; // Caché dans les réponses JSON
// Relations
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
Le trait HasUuids fait trois choses : il définit la clé primaire comme non auto-incrémentée, génère un UUID ordonné (format v4 avec préfixe de temps, tri lexicographique efficace pour les index B-tree) lors de la création, et adapte les migrations pour accepter des chaînes UUID. Dans la migration, remplacez $table->id() par $table->uuid('id')->primary(). Le cast 'status' => ArticleStatus::class mappe automatiquement la valeur string en base vers un Enum PHP natif — vous obtenez $article->status de type ArticleStatus au lieu d’une string brute.
Étape 3 — Travailler avec les relations Eloquent
Eloquent gère toutes les cardinalités classiques : hasOne, hasMany, belongsTo, belongsToMany, hasManyThrough, morphTo et leurs variantes. La clé pour écrire des relations efficaces est de comprendre quand charger les relations en eager loading (with()) versus lazy loading. Le lazy loading — charger la relation au moment de l’accès — est commode mais génère le problème N+1 : pour afficher 50 articles avec leur auteur, Laravel exécute 51 requêtes SQL (1 pour les articles + 50 pour chaque auteur).
<?php
// Problème N+1 — À ÉVITER en production
$articles = Article::all();
foreach ($articles as $article) {
echo $article->user->name; // Requête SQL à chaque itération !
}
// Eager loading — CORRECT : 2 requêtes SQL seulement
$articles = Article::with('user')->get();
foreach ($articles as $article) {
echo $article->user->name; // Déjà chargé en mémoire
}
// Eager loading conditionnel sur la relation (contrainte)
$articles = Article::with(['comments' => function ($query) {
$query->where('approved', true)->latest()->limit(3);
}])->get();
Pour détecter les requêtes N+1 en développement, activez la détection automatique dans app/Providers/AppServiceProvider.php :
use IlluminateDatabaseEloquentModel;
public function boot(): void
{
// Lance une exception si une relation est chargée en lazy loading
Model::preventLazyLoading(! app()->isProduction());
}
En développement, si vous oubliez un with(), Laravel lancera une LazyLoadingViolationException avec le nom du modèle et de la relation — impossible de rater le problème. En production, le paramètre ! app()->isProduction() désactive ce comportement pour ne pas interrompre les utilisateurs.
Étape 4 — Scopes locaux et globaux
Les query scopes sont des méthodes réutilisables de filtrage que vous ajoutez directement au modèle. Ils s’appliquent avec une syntaxe fluide chaînable et évitent de dupliquer des conditions where() dans plusieurs contrôleurs ou services. Les scopes locaux (préfixés scope) s’appliquent explicitement ; les scopes globaux s’appliquent à toutes les requêtes sur le modèle.
<?php
// Scopes locaux dans le modèle Article
// Articles publiés uniquement
public function scopePublished(IlluminateDatabaseEloquentBuilder $query): void
{
$query->where('status', 'published')
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
// Articles d'un auteur donné
public function scopeByAuthor(IlluminateDatabaseEloquentBuilder $query, int $userId): void
{
$query->where('user_id', $userId);
}
// Utilisation — chaînage fluide
$recentPublished = Article::published()
->byAuthor(auth()->id())
->with('user')
->latest('published_at')
->paginate(10);
Pour les scopes globaux — appliqués à chaque requête automatiquement — Laravel 11 introduit l’attribut #[ScopedBy] qui remplace avantageusement l’ancienne méthode boot() dans le modèle :
<?php
// app/Models/Scopes/PublishedScope.php
namespace AppModelsScopes;
use IlluminateDatabaseEloquentBuilder;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentScope;
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('status', 'published');
}
}
// app/Models/Article.php — application déclarative du scope global
use AppModelsScopesPublishedScope;
use IlluminateDatabaseEloquentAttributesScopedBy;
#[ScopedBy([PublishedScope::class])]
class Article extends Model
{
// Toutes les requêtes Article incluront automatiquement WHERE status = 'published'
}
Pour ignorer le scope global sur une requête spécifique (par exemple dans une interface d’administration), utilisez Article::withoutGlobalScope(PublishedScope::class)->get().
Étape 5 — Optimiser les requêtes : select, chunk, cursor et lazy
Eloquent offre plusieurs stratégies pour traiter de grands volumes de données sans saturer la mémoire PHP. La méthode all() charge tout en mémoire d’un coup — acceptable pour quelques dizaines de lignes, catastrophique sur 100 000 enregistrements. Voici les alternatives selon le cas d’usage :
<?php
// Sélectionner uniquement les colonnes nécessaires (économie mémoire + requête plus rapide)
$articles = Article::select('id', 'title', 'slug', 'published_at')->published()->get();
// chunk() — traitement par lots (idéal pour les exports ou transformations en masse)
Article::published()->chunk(500, function ($articles) {
foreach ($articles as $article) {
// Traitement — par ex. générer un PDF ou envoyer un email
dispatch(new GenerateArticlePdf($article));
}
});
// lazy() — générateur Eloquent (mémoire faible, idéal pour les scripts CLI)
foreach (Article::published()->lazy() as $article) {
// Un seul objet Article en mémoire à la fois
$article->update(['view_count' => 0]);
}
// cursor() — générateur bas niveau via PDO (le plus économe en mémoire)
foreach (Article::published()->cursor() as $article) {
// Pas de collection Eloquent — requête streaming
echo $article->title . PHP_EOL;
}
La règle de décision est simple : utilisez get() pour de petits datasets, chunk() pour les traitements en masse avec dispatch de jobs, lazy() quand vous avez besoin des fonctionnalités Eloquent complètes avec peu de mémoire, et cursor() pour les scripts CLI qui lisent uniquement sans modifier.
Étape 6 — Soft Deletes et la commande model:prune
Les soft deletes permettent de « supprimer logiquement » un enregistrement sans le retirer physiquement de la base de données : la colonne deleted_at est remplie et Eloquent exclut automatiquement ces lignes de toutes les requêtes. C’est une fonctionnalité critique pour les applications qui ont besoin d’un historique ou d’une corbeille de récupération.
<?php
// Migration : ajouter la colonne deleted_at
$table->softDeletes(); // ajoute nullable timestamp deleted_at
// Utilisation dans le modèle
use IlluminateDatabaseEloquentSoftDeletes;
class Article extends Model
{
use SoftDeletes;
}
// Opérations
$article->delete(); // Soft delete (deleted_at = now())
Article::withTrashed()->get(); // Inclure les soft-deleted
Article::onlyTrashed()->get(); // Seulement les soft-deleted
$article->restore(); // Restaurer
$article->forceDelete(); // Suppression physique définitive
Pour purger automatiquement les anciens enregistrements supprimés après un délai, implémentez l’interface Prunable et planifiez model:prune via le scheduler :
<?php
use IlluminateDatabaseEloquentPrunable;
class Article extends Model
{
use SoftDeletes, Prunable;
// Purge les articles supprimés depuis plus de 30 jours
public function prunable(): IlluminateDatabaseEloquentBuilder
{
return static::where('deleted_at', '<=', now()->subDays(30));
}
}
// Dans routes/console.php
Schedule::command('model:prune')->daily();
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
LazyLoadingViolationException |
Relation accédée sans eager loading et preventLazyLoading activé |
Ajouter with('relation') à la requête Eloquent |
| UUID non générés à la création | Migration utilise $table->id() au lieu de $table->uuid('id')->primary() |
Corriger la migration et relancer migrate:fresh |
| Soft-deleted apparaissent dans les résultats | Oubli du trait SoftDeletes dans le modèle |
Ajouter use SoftDeletes; et vérifier que la colonne deleted_at existe |
| Scope global non appliqué | Utilisation de ->boot() au lieu de #[ScopedBy] sans rétrocompatibilité |
Migrer vers #[ScopedBy([MyScope::class])] ou garder la méthode boot() |
FAQ
Quelle différence entre $fillable et $guarded ?
$fillable est une liste blanche (seuls les champs listés sont acceptés). $guarded est une liste noire (tous les champs sauf ceux listés sont acceptés). Pour les projets avec beaucoup de champs, $guarded = [] (tout autoriser) est plus commode mais moins sécurisé — utilisez-le uniquement si vous contrôlez scrupuleusement ce qui est passé à create().
HasUuids génère-t-il des UUIDs v4 ou v7 ?
Dans Laravel 11, HasUuids génère des UUID ordonnés basés sur un format v4 avec préfixe temporel (orderedUuid). Le vrai UUID v7 natif est disponible depuis Laravel 12 via le trait HasVersion7Uuids, qui sont mieux adaptés aux indexes de base de données que les UUID v4 aléatoires — ils maintiennent l’ordre d’insertion et réduisent la fragmentation des pages B-tree.
Peut-on utiliser des Enum PHP natifs comme casts ?
Oui. Depuis Laravel 9 et PHP 8.1, vous pouvez caster un attribut vers un BackedEnum PHP : 'status' => ArticleStatus::class. L’Enum doit implémenter string ou int comme type de backing.
Tutoriels frères dans cette série
- Créer une API REST avec Laravel 11
- Authentification avec Laravel Sanctum
- Queues et jobs Laravel 11
- Tester son application avec Pest