Eloquent ORM: patronen en optimalisaties voor Laravel

Beheers Eloquent ORM met geavanceerde patronen en optimalisatietechnieken. Eager loading, query scopes, accessors, mutators en performance voor Laravel-applicaties.

Eloquent ORM-patronen en -optimalisaties voor Laravel

Eloquent ORM zet database-interacties om in vloeiende en expressieve operaties. Naast de elegante syntax bepaalt de beheersing van geavanceerde patronen en optimalisatietechnieken de prestaties van Laravel-applicaties in productie.

Performance eerst

Het N+1-probleem is de belangrijkste oorzaak van traagheid in Eloquent-applicaties. Elke niet-geoptimaliseerde relatie genereert een extra SQL-query per record.

Het N+1-probleem oplossen met eager loading

Het N+1-probleem doet zich voor wanneer elke iteratie over een collectie een extra query activeert om relaties te laden. Met 100 artikelen en hun auteurs zijn dat 101 queries in plaats van één geoptimaliseerde.

Eager loading haalt alle relaties op in maximaal één of twee queries en verkort zo de responstijd drastisch.

app/Http/Controllers/ArticleController.phpphp
// Demonstration of N+1 problem and its solution

namespace App\Http\Controllers;

use App\Models\Article;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
    // ❌ N+1 problem: 1 articles query + N author queries
    public function indexWithProblem()
    {
        $articles = Article::all(); // 1 query

        foreach ($articles as $article) {
            echo $article->author->name; // N additional queries
        }
    }

    // ✅ Eager loading: 2 queries maximum
    public function indexOptimized()
    {
        $articles = Article::with('author')->get(); // 2 queries total

        foreach ($articles as $article) {
            echo $article->author->name; // No additional queries
        }
    }

    // ✅ Nested eager loading for multiple relationships
    public function indexWithNestedRelations()
    {
        // Loads articles → authors → profiles + articles → comments → users
        $articles = Article::with([
            'author.profile',
            'comments.user'
        ])->get();

        return view('articles.index', compact('articles'));
    }
}

Eager loading met with() anticipeert op de behoeften en laadt de gegevens vooraf. Het verschil in prestaties wordt spectaculair bij grote collecties.

Voorwaardelijke en beperkte eager loading

Grote relaties vereisen soms gedeeltelijk laden. Beperkingen op de eager loading begrenzen de opgehaalde data en voorkomen tegelijkertijd N+1.

app/Http/Controllers/UserController.phpphp
// Eager loading with constraints to optimize queries

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;

class UserController extends Controller
{
    public function showWithRecentOrders(int $id)
    {
        // Load only the 5 most recent paid orders
        $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()
    {
        // Conditional eager loading with withCount
        $users = User::query()
            ->where('active', true)
            ->with(['profile', 'subscription'])
            ->withCount(['orders', 'reviews']) // Adds orders_count and reviews_count
            ->withSum('orders', 'total')       // Adds orders_sum_total
            ->get();

        return view('users.index', compact('users'));
    }

    public function showWithConditionalRelation(int $id)
    {
        // Load relationship only if user is premium
        $user = User::findOrFail($id);

        $user->loadMissing(
            $user->isPremium() ? ['premiumFeatures', 'analytics'] : []
        );

        return view('users.show', compact('user'));
    }
}

De methoden withCount() en withSum() voegen aggregaties toe zonder volledige collecties te laden, ideaal voor dashboardstatistieken.

Query scopes voor herbruikbare queries

Query scopes kapselen de filterlogica binnen het model. Lokale scopes bieden flexibiliteit terwijl globale scopes automatisch op alle queries worden toegepast.

Naamgevingsconventie

Lokale scopes gebruiken het voorvoegsel scope in het model maar worden zonder dat aangeroepen: scopeActive() wordt User::active().

app/Models/Article.phpphp
// Local and global scopes to encapsulate business logic

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy([PublishedScope::class])] // Global scope via PHP 8 attribute
class Article extends Model
{
    // Simple local scope: Article::published()
    public function scopePublished(Builder $query): Builder
    {
        return $query->whereNotNull('published_at')
                     ->where('published_at', '<=', now());
    }

    // Local scope with parameter: Article::byCategory('tech')
    public function scopeByCategory(Builder $query, string $category): Builder
    {
        return $query->where('category', $category);
    }

    // Local scope with optional parameter
    public function scopePopular(Builder $query, int $minViews = 1000): Builder
    {
        return $query->where('views_count', '>=', $minViews)
                     ->orderByDesc('views_count');
    }

    // Dynamic scope for flexible search
    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
// Reusable global scope across models

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
    {
        // Automatically applied to all Article queries
        $builder->whereNotNull('published_at')
                ->where('published_at', '<=', now());
    }
}
php
// Using scopes in a controller
$articles = Article::query()
    ->byCategory('technology')
    ->popular(500)
    ->search($request->input('q'))
    ->with('author')
    ->paginate(20);

// Disable a global scope temporarily
$allArticles = Article::withoutGlobalScope(PublishedScope::class)->get();

Het aaneenketenen van scopes levert leesbare en onderhoudbare queries op terwijl de bedrijfslogica in het model wordt gecentraliseerd.

Klaar om je Laravel gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Accessors en mutators met Attribute

Laravel 9+ introduceert een uniforme syntax voor accessors en mutators via de klasse Attribute. Deze moderne aanpak vervangt de get*Attribute- en set*Attribute-methoden.

app/Models/User.phpphp
// Modern accessors and mutators with the Attribute class

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: generates a virtual attribute from other fields
    protected function fullName(): Attribute
    {
        return Attribute::make(
            get: fn () => "{$this->first_name} {$this->last_name}",
        );
    }

    // Mutator: transforms value before saving
    protected function password(): Attribute
    {
        return Attribute::make(
            set: fn (string $value) => Hash::make($value),
        );
    }

    // Combined accessor + mutator
    protected function email(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => Str::lower($value),
            set: fn (string $value) => Str::lower(trim($value)),
        );
    }

    // Cached accessor to avoid recalculations
    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(); // Caches result during request
    }

    // Computed attribute based on a relationship
    protected function ordersTotal(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->orders->sum('total'),
        );
    }
}
php
// Transparent usage of accessors and mutators
$user = new User();
$user->first_name = 'John';
$user->last_name = 'Doe';
$user->email = '  JOHN@EXAMPLE.COM  '; // Automatically normalized
$user->password = 'secret123';          // Automatically hashed
$user->save();

echo $user->full_name;  // "John Doe"
echo $user->initials;   // "JD"
echo $user->email;      // "john@example.com"

De methode shouldCache() optimaliseert dure accessors door meerdere herberekeningen op hetzelfde model te vermijden.

Eigen casts voor complexe types

Casts transformeren waarden automatisch tussen PHP en de database. Eigen casts kapselen de serialisatielogica voor bedrijfstypes.

app/Casts/MoneyCast.phpphp
// Custom cast for handling monetary amounts

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 = 'USD'
    ) {}

    // DB → PHP conversion: cents to Money object
    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
        );
    }

    // PHP → DB conversion: Money object to cents
    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
// Immutable Value Object representing amounts

namespace App\ValueObjects;

use JsonSerializable;

final readonly class Money implements JsonSerializable
{
    public function __construct(
        private int $amount,     // Stored in cents
        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
// Using the custom cast

namespace App\Models;

use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    protected function casts(): array
    {
        return [
            'total' => MoneyCast::class,           // USD by default
            'shipping_cost' => MoneyCast::class . ':USD',
            'tax_amount' => MoneyCast::class . ':USD',
            'paid_at' => 'datetime',
            'metadata' => 'array',
        ];
    }
}
php
// Natural usage with the cast
$order = Order::find(1);
echo $order->total->format();              // "149.99 USD"
echo $order->total->getAmountInCents();    // 14999

$order->total = 199.99;  // Automatically converted
$order->save();          // Stored as 19999 in DB

Value Objects gecombineerd met eigen casts garanderen de integriteit van bedrijfsdata terwijl een elegante API behouden blijft.

Grote queries optimaliseren

Operaties op miljoenen records vereisen specifieke technieken om geheugenuitputting te vermijden. Chunking en cursors verwerken de gegevens in batches.

Let op het geheugen

Model::all() laadt alle records in het geheugen. Op een tabel van 100.000 rijen kan dit meerdere gigabytes RAM gebruiken en de applicatie laten crashen.

app/Console/Commands/ProcessUsersCommand.phpphp
// Batch processing techniques for large 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: processes in batches of 1000, reloads from DB
        User::query()
            ->where('needs_processing', true)
            ->chunk(1000, function ($users) {
                foreach ($users as $user) {
                    $user->processAccount();
                }
            });

        // ✅ Chunk with updates: avoids infinite loop during modifications
        User::query()
            ->where('status', 'pending')
            ->chunkById(1000, function ($users) {
                foreach ($users as $user) {
                    $user->update(['status' => 'processed']);
                }
            });

        // ✅ Lazy collection: single record in memory at a time
        foreach (User::lazy(1000) as $user) {
            $user->sendNewsletter();
        }

        // ✅ Cursor: for read-only operations, minimal memory
        foreach (User::cursor() as $user) {
            $this->info("Processing: {$user->email}");
        }

        // ✅ Mass update without Eloquent: maximum performance
        User::query()
            ->where('last_login_at', '<', now()->subYear())
            ->update(['status' => 'inactive']);

        // ✅ Optimized mass deletion
        User::query()
            ->where('deleted_at', '<', now()->subMonths(6))
            ->forceDelete();

        return self::SUCCESS;
    }
}

De keuze tussen chunk(), lazy() en cursor() hangt af van de use case: chunk() voor wijzigingen, lazy() voor tussenliggende operaties en cursor() voor enkel lezen met minimaal geheugenverbruik.

Geavanceerde polymorfe relaties

Polymorfe relaties laten een model toe om via één relatie tot meerdere verschillende modeltypes te behoren. Deze flexibiliteit is ideaal voor reacties, tags of bijgevoegde bestanden.

app/Models/Comment.phpphp
// Model with inverse polymorphic relationship

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    protected $fillable = ['body', 'user_id'];

    // A comment can belong to Article, Video, or any other model
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}
app/Models/Article.phpphp
// Parent model with polymorphic relationship

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
// Another parent model using the same relationship

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 for polymorphic comments table
// 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'); // Creates commentable_type and commentable_id
            $table->timestamps();

            // Composite index for polymorphic queries
            $table->index(['commentable_type', 'commentable_id']);
        });
    }
};
php
// Using polymorphic relationships
$article = Article::find(1);
$article->comments()->create([
    'body' => 'Excellent article!',
    'user_id' => auth()->id(),
]);

$video = Video::find(1);
$video->comments()->create([
    'body' => 'Very instructive video',
    'user_id' => auth()->id(),
]);

// Retrieve parent from comment
$comment = Comment::with('commentable')->find(1);
echo get_class($comment->commentable); // App\Models\Article or App\Models\Video

Polymorfe relaties vermijden tabelduplicatie en centraliseren de logica voor transversale functionaliteiten.

Klaar om je Laravel gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Traits en observers voor herbruikbare logica

Traits kapselen herbruikbare gedragingen over modellen heen. Observers centraliseren hooks op levenscyclusgebeurtenissen.

app/Models/Concerns/HasSlug.phpphp
// Trait for automatic slug generation

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;

        // Check uniqueness and add suffix if needed
        while (static::where('slug', $slug)->exists()) {
            $slug = "{$originalSlug}-{$counter}";
            $counter++;
        }

        return $slug;
    }

    // Can be overridden in the model
    protected function getSlugSource(): string
    {
        return $this->title ?? $this->name;
    }
}
app/Models/Concerns/HasUuid.phpphp
// Trait for using UUIDs as primary key

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 to centralize hooks on Article model

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
    {
        // Invalidate recent articles cache
        Cache::tags(['articles', 'recent'])->flush();

        // Index for search
        $this->searchIndex->index($article);
    }

    public function updated(Article $article): void
    {
        // Update search index
        $this->searchIndex->update($article);

        // Notify subscribers if article was just published
        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();
    }

    // Prevent deletion if article has comments
    public function deleting(Article $article): bool
    {
        if ($article->comments()->exists()) {
            return false; // Cancel deletion
        }
        return true;
    }
}
app/Models/Article.phpphp
// Model using traits and 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'];
}

De boot*-methoden van traits worden automatisch uitgevoerd tijdens de modelinitialisatie en zorgen voor een transparante integratie.

Conclusie

Eloquent ORM beheersen vereist begrip van de onderliggende mechanismen en het toepassen van de juiste patronen. De gepresenteerde technieken transformeren naïeve queries in performante en onderhoudbare code.

Eloquent-optimalisatiechecklist:

✅ Systematisch with() gebruiken voor weergegeven relaties ✅ withCount() toepassen in plaats van collecties laden om te tellen ✅ Filterlogica in query scopes kapselen ✅ Accessors verkiezen boven herhaalde berekeningen in views ✅ Eigen casts implementeren voor zakelijke Value Objects ✅ chunk() of lazy() gebruiken voor operaties op grote tabellen ✅ Neveneffecten centraliseren in observers ✅ Gemeenschappelijk gedrag extraheren naar traits

De tools php artisan telescope of laravel-debugbar maken de gegenereerde SQL-queries zichtbaar en helpen ontbrekende optimalisaties te identificeren.

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

#laravel
#eloquent
#php
#orm
#database

Delen

Gerelateerde artikelen