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 patterns et optimisations 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.

Performance avant tout

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.

app/Http/Controllers/ArticleController.phpphp
// 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.

app/Http/Controllers/UserController.phpphp
// 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.

Convention de nommage

Les scopes locaux utilisent le préfixe scope dans le modèle mais s'appellent sans ce préfixe : scopeActive() devient User::active().

app/Models/Article.phpphp
// 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}%"));
        });
    }
}
app/Models/Scopes/PublishedScope.phpphp
// 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());
    }
}
php
// 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.

app/Models/User.phpphp
// 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'),
        );
    }
}
php
// 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.

app/Casts/MoneyCast.phpphp
// 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');
    }
}
app/ValueObjects/Money.phpphp
// 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,
        ];
    }
}
app/Models/Order.phpphp
// 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',
        ];
    }
}
php
// 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 DB

Les 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.

Attention mémoire

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.

app/Console/Commands/ProcessUsersCommand.phpphp
// 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.

app/Models/Comment.phpphp
// 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);
    }
}
app/Models/Article.phpphp
// 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');
    }
}
app/Models/Video.phpphp
// 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');
    }
}
php
// 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']);
        });
    }
};
php
// 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\Video

Les 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.

app/Models/Concerns/HasSlug.phpphp
// 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;
    }
}
app/Models/Concerns/HasUuid.phpphp
// 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';
    }
}
app/Observers/ArticleObserver.phpphp
// 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;
    }
}
app/Models/Article.phpphp
// 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

#laravel
#eloquent
#php
#orm
#database

Partager

Articles similaires