Eloquent ORM : Patterns et optimisations pour Laravel
Maîtrisez Eloquent ORM avec les patterns avancés et techniques d'optimisation. Eager loading, query scopes, accessors, mutators et performance pour applications Laravel.

Eloquent ORM transforme les interactions avec la base de données en opérations fluides et expressives. Au-delà de la syntaxe élégante, la maîtrise des patterns avancés et des techniques d'optimisation détermine la performance des applications Laravel en production.
Le problème N+1 est la cause principale de lenteur dans les applications Eloquent. Chaque relation non optimisée génère une requête SQL supplémentaire par enregistrement.
Résolution du problème N+1 avec Eager Loading
Le problème N+1 survient lorsque chaque itération sur une collection déclenche une requête additionnelle pour charger les relations. Avec 100 articles et leurs auteurs, cela représente 101 requêtes au lieu d'une seule optimisée.
L'eager loading charge toutes les relations en une ou deux requêtes maximum, réduisant drastiquement le temps de réponse.
// Démonstration du problème N+1 et de sa solution
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\Request;
class ArticleController extends Controller
{
// ❌ Problème N+1 : 1 requête articles + N requêtes auteurs
public function indexWithProblem()
{
$articles = Article::all(); // 1 requête
foreach ($articles as $article) {
echo $article->author->name; // N requêtes supplémentaires
}
}
// ✅ Eager loading : 2 requêtes maximum
public function indexOptimized()
{
$articles = Article::with('author')->get(); // 2 requêtes total
foreach ($articles as $article) {
echo $article->author->name; // Aucune requête additionnelle
}
}
// ✅ Eager loading imbriqué pour relations multiples
public function indexWithNestedRelations()
{
// Charge articles → auteurs → profils + articles → commentaires → utilisateurs
$articles = Article::with([
'author.profile',
'comments.user'
])->get();
return view('articles.index', compact('articles'));
}
}L'eager loading avec with() anticipe les besoins et charge les données en amont. La différence de performance devient spectaculaire sur des collections volumineuses.
Eager Loading conditionnel et contraint
Les relations volumineuses nécessitent parfois un chargement partiel. Les contraintes sur l'eager loading permettent de limiter les données récupérées tout en évitant le N+1.
// Eager loading avec contraintes pour optimiser les requêtes
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UserController extends Controller
{
public function showWithRecentOrders(int $id)
{
// Charge uniquement les 5 dernières commandes payées
$user = User::with(['orders' => function (Builder $query) {
$query->where('status', 'paid')
->orderByDesc('created_at')
->limit(5);
}])->findOrFail($id);
return view('users.show', compact('user'));
}
public function indexActiveWithStats()
{
// Eager loading conditionnel avec withCount
$users = User::query()
->where('active', true)
->with(['profile', 'subscription'])
->withCount(['orders', 'reviews']) // Ajoute orders_count et reviews_count
->withSum('orders', 'total') // Ajoute orders_sum_total
->get();
return view('users.index', compact('users'));
}
public function showWithConditionalRelation(int $id)
{
// Charge la relation seulement si l'utilisateur est premium
$user = User::findOrFail($id);
$user->loadMissing(
$user->isPremium() ? ['premiumFeatures', 'analytics'] : []
);
return view('users.show', compact('user'));
}
}Les méthodes withCount() et withSum() ajoutent des agrégations sans charger les collections complètes, idéal pour les statistiques de tableaux de bord.
Query Scopes pour des requêtes réutilisables
Les query scopes encapsulent la logique de filtrage dans le modèle. Les scopes locaux offrent de la flexibilité tandis que les scopes globaux s'appliquent automatiquement à toutes les requêtes.
Les scopes locaux utilisent le préfixe scope dans le modèle mais s'appellent sans ce préfixe : scopeActive() devient User::active().
// Scopes locaux et globaux pour encapsuler la logique métier
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([PublishedScope::class])] // Scope global via attribut PHP 8
class Article extends Model
{
// Scope local simple : Article::published()
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
// Scope local avec paramètre : Article::byCategory('tech')
public function scopeByCategory(Builder $query, string $category): Builder
{
return $query->where('category', $category);
}
// Scope local avec paramètre optionnel
public function scopePopular(Builder $query, int $minViews = 1000): Builder
{
return $query->where('views_count', '>=', $minViews)
->orderByDesc('views_count');
}
// Scope dynamique pour recherche flexible
public function scopeSearch(Builder $query, ?string $term): Builder
{
if (empty($term)) {
return $query;
}
return $query->where(function (Builder $q) use ($term) {
$q->where('title', 'like', "%{$term}%")
->orWhere('content', 'like', "%{$term}%")
->orWhereHas('tags', fn($t) => $t->where('name', 'like', "%{$term}%"));
});
}
}// Scope global réutilisable entre modèles
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
// Appliqué automatiquement à toutes les requêtes Article
$builder->whereNotNull('published_at')
->where('published_at', '<=', now());
}
}// Utilisation des scopes dans un contrôleur
$articles = Article::query()
->byCategory('technology')
->popular(500)
->search($request->input('q'))
->with('author')
->paginate(20);
// Désactiver un scope global ponctuellement
$allArticles = Article::withoutGlobalScope(PublishedScope::class)->get();Les scopes chaînés créent des requêtes lisibles et maintenables tout en centralisant la logique métier dans le modèle.
Prêt à réussir tes entretiens Laravel ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Accessors et Mutators avec les Attributs
Laravel 9+ introduit une syntaxe unifiée pour les accessors et mutators via la classe Attribute. Cette approche moderne remplace les méthodes get*Attribute et set*Attribute.
// Accessors et mutators modernes avec la classe Attribute
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class User extends Model
{
protected $fillable = ['first_name', 'last_name', 'email', 'password'];
// Accessor : génère un attribut virtuel à partir d'autres champs
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}",
);
}
// Mutator : transforme la valeur avant l'enregistrement
protected function password(): Attribute
{
return Attribute::make(
set: fn (string $value) => Hash::make($value),
);
}
// Accessor + Mutator combinés
protected function email(): Attribute
{
return Attribute::make(
get: fn (string $value) => Str::lower($value),
set: fn (string $value) => Str::lower(trim($value)),
);
}
// Accessor avec mise en cache pour éviter les recalculs
protected function initials(): Attribute
{
return Attribute::make(
get: fn () => Str::upper(
Str::substr($this->first_name, 0, 1) .
Str::substr($this->last_name, 0, 1)
),
)->shouldCache(); // Cache le résultat pendant la requête
}
// Attribut calculé basé sur une relation
protected function ordersTotal(): Attribute
{
return Attribute::make(
get: fn () => $this->orders->sum('total'),
);
}
}// Utilisation transparente des accessors et mutators
$user = new User();
$user->first_name = 'Jean';
$user->last_name = 'Dupont';
$user->email = ' JEAN@EXAMPLE.COM '; // Automatiquement normalisé
$user->password = 'secret123'; // Automatiquement hashé
$user->save();
echo $user->full_name; // "Jean Dupont"
echo $user->initials; // "JD"
echo $user->email; // "jean@example.com"La méthode shouldCache() optimise les accessors coûteux en évitant les recalculs multiples sur le même modèle.
Casts personnalisés pour types complexes
Les casts transforment automatiquement les valeurs entre PHP et la base de données. Les casts personnalisés encapsulent la logique de sérialisation pour les types métier.
// Cast personnalisé pour gérer les montants monétaires
namespace App\Casts;
use App\ValueObjects\Money;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class MoneyCast implements CastsAttributes
{
public function __construct(
protected string $currency = 'EUR'
) {}
// Conversion DB → PHP : centimes vers objet Money
public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
{
if ($value === null) {
return null;
}
return new Money(
amount: (int) $value,
currency: $this->currency
);
}
// Conversion PHP → DB : objet Money vers centimes
public function set(Model $model, string $key, mixed $value, array $attributes): ?int
{
if ($value === null) {
return null;
}
if ($value instanceof Money) {
return $value->getAmountInCents();
}
if (is_numeric($value)) {
return (int) ($value * 100);
}
throw new InvalidArgumentException('Value must be Money instance or numeric');
}
}// Value Object immuable pour représenter les montants
namespace App\ValueObjects;
use JsonSerializable;
final readonly class Money implements JsonSerializable
{
public function __construct(
private int $amount, // Stocké en centimes
private string $currency
) {}
public function getAmountInCents(): int
{
return $this->amount;
}
public function getAmountInUnits(): float
{
return $this->amount / 100;
}
public function format(): string
{
return number_format($this->getAmountInUnits(), 2, ',', ' ') . ' ' . $this->currency;
}
public function add(Money $other): self
{
return new self($this->amount + $other->amount, $this->currency);
}
public function jsonSerialize(): array
{
return [
'amount' => $this->getAmountInUnits(),
'currency' => $this->currency,
];
}
}// Utilisation du cast personnalisé
namespace App\Models;
use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected function casts(): array
{
return [
'total' => MoneyCast::class, // EUR par défaut
'shipping_cost' => MoneyCast::class . ':EUR',
'tax_amount' => MoneyCast::class . ':EUR',
'paid_at' => 'datetime',
'metadata' => 'array',
];
}
}// Utilisation naturelle avec le cast
$order = Order::find(1);
echo $order->total->format(); // "149,99 EUR"
echo $order->total->getAmountInCents(); // 14999
$order->total = 199.99; // Automatiquement converti
$order->save(); // Stocké comme 19999 en DBLes Value Objects combinés aux casts personnalisés garantissent l'intégrité des données métier tout en conservant une API élégante.
Optimisation des requêtes volumineuses
Les opérations sur des millions d'enregistrements nécessitent des techniques spécifiques pour éviter l'épuisement mémoire. Le chunking et les curseurs permettent de traiter les données par lots.
Model::all() charge tous les enregistrements en mémoire. Sur une table de 100 000 lignes, cela peut consommer plusieurs gigaoctets de RAM et crasher l'application.
// Techniques de traitement par lots pour grandes tables
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class ProcessUsersCommand extends Command
{
protected $signature = 'users:process';
public function handle(): int
{
// ✅ Chunk : traite par lots de 1000, recharge depuis DB
User::query()
->where('needs_processing', true)
->chunk(1000, function ($users) {
foreach ($users as $user) {
$user->processAccount();
}
});
// ✅ Chunk avec mise à jour : évite boucle infinie lors de modifications
User::query()
->where('status', 'pending')
->chunkById(1000, function ($users) {
foreach ($users as $user) {
$user->update(['status' => 'processed']);
}
});
// ✅ Lazy collection : un seul enregistrement en mémoire à la fois
foreach (User::lazy(1000) as $user) {
$user->sendNewsletter();
}
// ✅ Cursor : pour lecture seule, mémoire minimale
foreach (User::cursor() as $user) {
$this->info("Processing: {$user->email}");
}
// ✅ Mise à jour massive sans Eloquent : performance maximale
User::query()
->where('last_login_at', '<', now()->subYear())
->update(['status' => 'inactive']);
// ✅ Suppression massive optimisée
User::query()
->where('deleted_at', '<', now()->subMonths(6))
->forceDelete();
return self::SUCCESS;
}
}Le choix entre chunk(), lazy() et cursor() dépend du cas d'usage : chunk() pour les modifications, lazy() pour les opérations intermédiaires, cursor() pour la lecture simple avec mémoire minimale.
Relations polymorphiques avancées
Les relations polymorphiques permettent à un modèle d'appartenir à plusieurs types de modèles différents via une seule relation. Cette flexibilité est idéale pour les commentaires, tags, ou fichiers attachés.
// Modèle avec relation polymorphique inverse
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Comment extends Model
{
protected $fillable = ['body', 'user_id'];
// Un commentaire peut appartenir à Article, Video, ou tout autre modèle
public function commentable(): MorphTo
{
return $this->morphTo();
}
public function user()
{
return $this->belongsTo(User::class);
}
}// Modèle parent avec relation polymorphique
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Article extends Model
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}// Autre modèle parent utilisant la même relation
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Video extends Model
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}// Migration pour la table comments polymorphique
// database/migrations/2026_01_15_create_comments_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->text('body');
$table->foreignId('user_id')->constrained();
$table->morphs('commentable'); // Crée commentable_type et commentable_id
$table->timestamps();
// Index composite pour les requêtes polymorphiques
$table->index(['commentable_type', 'commentable_id']);
});
}
};// Utilisation des relations polymorphiques
$article = Article::find(1);
$article->comments()->create([
'body' => 'Excellent article !',
'user_id' => auth()->id(),
]);
$video = Video::find(1);
$video->comments()->create([
'body' => 'Vidéo très instructive',
'user_id' => auth()->id(),
]);
// Récupérer le parent depuis le commentaire
$comment = Comment::with('commentable')->find(1);
echo get_class($comment->commentable); // App\Models\Article ou App\Models\VideoLes relations polymorphiques évitent la duplication de tables et centralisent la logique pour les fonctionnalités transversales.
Prêt à réussir tes entretiens Laravel ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Traits et Observers pour logique réutilisable
Les traits encapsulent des comportements réutilisables entre modèles. Les observers centralisent les hooks sur les événements du cycle de vie.
// Trait pour génération automatique de slugs
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
trait HasSlug
{
public static function bootHasSlug(): void
{
static::creating(function (Model $model) {
if (empty($model->slug)) {
$model->slug = $model->generateUniqueSlug();
}
});
}
protected function generateUniqueSlug(): string
{
$slug = Str::slug($this->getSlugSource());
$originalSlug = $slug;
$counter = 1;
// Vérifie l'unicité et ajoute un suffixe si nécessaire
while (static::where('slug', $slug)->exists()) {
$slug = "{$originalSlug}-{$counter}";
$counter++;
}
return $slug;
}
// Peut être surchargée dans le modèle
protected function getSlugSource(): string
{
return $this->title ?? $this->name;
}
}// Trait pour utiliser des UUID comme clé primaire
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
trait HasUuid
{
public static function bootHasUuid(): void
{
static::creating(function (Model $model) {
if (empty($model->{$model->getKeyName()})) {
$model->{$model->getKeyName()} = (string) Str::uuid();
}
});
}
public function getIncrementing(): bool
{
return false;
}
public function getKeyType(): string
{
return 'string';
}
}// Observer pour centraliser les hooks sur le modèle Article
namespace App\Observers;
use App\Models\Article;
use App\Jobs\NotifySubscribersJob;
use App\Services\SearchIndexService;
use Illuminate\Support\Facades\Cache;
class ArticleObserver
{
public function __construct(
private SearchIndexService $searchIndex
) {}
public function created(Article $article): void
{
// Invalide le cache des articles récents
Cache::tags(['articles', 'recent'])->flush();
// Indexe pour la recherche
$this->searchIndex->index($article);
}
public function updated(Article $article): void
{
// Met à jour l'index de recherche
$this->searchIndex->update($article);
// Notifie les abonnés si l'article vient d'être publié
if ($article->wasChanged('published_at') && $article->published_at !== null) {
NotifySubscribersJob::dispatch($article);
}
Cache::tags(['articles'])->flush();
}
public function deleted(Article $article): void
{
$this->searchIndex->remove($article);
Cache::tags(['articles'])->flush();
}
// Empêche la suppression si l'article a des commentaires
public function deleting(Article $article): bool
{
if ($article->comments()->exists()) {
return false; // Annule la suppression
}
return true;
}
}// Modèle utilisant les traits et l'observer
namespace App\Models;
use App\Models\Concerns\HasSlug;
use App\Models\Concerns\HasUuid;
use App\Observers\ArticleObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;
#[ObservedBy(ArticleObserver::class)]
class Article extends Model
{
use HasSlug, HasUuid;
protected $fillable = ['title', 'content', 'published_at'];
}Les traits boot* s'exécutent automatiquement lors de l'initialisation du modèle, permettant une intégration transparente.
Conclusion
La maîtrise d'Eloquent ORM repose sur la compréhension des mécanismes sous-jacents et l'application des patterns appropriés. Les techniques présentées transforment des requêtes naïves en code performant et maintenable.
Checklist des optimisations Eloquent :
✅ Utiliser systématiquement with() pour les relations affichées
✅ Appliquer withCount() plutôt que charger des collections pour compter
✅ Encapsuler la logique de filtrage dans des query scopes
✅ Préférer les accessors aux calculs répétés dans les vues
✅ Implémenter des casts personnalisés pour les Value Objects métier
✅ Utiliser chunk() ou lazy() pour les opérations sur grandes tables
✅ Centraliser les side effects dans des observers
✅ Extraire les comportements communs dans des traits
L'outil php artisan telescope ou laravel-debugbar permet de visualiser les requêtes SQL générées et d'identifier les optimisations manquantes.
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

Questions d'entretien Laravel et PHP : Top 25 en 2026
Les 25 questions d'entretien Laravel et PHP les plus posées. Eloquent ORM, middleware, artisan, queues, tests et architecture avec réponses détaillées et exemples de code.

Laravel 11 : Créer une application complète de A à Z
Guide complet pour créer une application Laravel 11 avec authentification, API REST, Eloquent ORM et déploiement. Tutorial pratique pour débutants et intermédiaires.