Eloquent ORM: Patterns und Optimierungen für Laravel

Eloquent ORM mit fortgeschrittenen Patterns und Optimierungstechniken meistern. Eager Loading, Query Scopes, Accessors, Mutatoren und Performance für Laravel-Anwendungen.

Eloquent ORM Patterns und Optimierungen für Laravel

Eloquent ORM verwandelt Datenbankinteraktionen in flüssige und ausdrucksstarke Operationen. Über die elegante Syntax hinaus entscheidet die Beherrschung fortgeschrittener Patterns und Optimierungstechniken über die Performance von Laravel-Anwendungen in der Produktion.

Performance zuerst

Das N+1-Problem ist die häufigste Ursache für Langsamkeit in Eloquent-Anwendungen. Jede nicht optimierte Beziehung erzeugt eine zusätzliche SQL-Abfrage pro Datensatz.

Das N+1-Problem mit Eager Loading lösen

Das N+1-Problem entsteht, wenn jede Iteration über eine Sammlung eine zusätzliche Abfrage zum Laden der Beziehungen auslöst. Bei 100 Artikeln und ihren Autoren ergeben sich 101 Abfragen statt einer einzigen optimierten.

Eager Loading lädt alle Beziehungen in maximal einer oder zwei Abfragen und reduziert so die Antwortzeit 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 mit with() antizipiert die Bedürfnisse und lädt die Daten im Voraus. Der Performance-Unterschied wird bei großen Sammlungen spektakulär.

Bedingtes und eingeschränktes Eager Loading

Umfangreiche Beziehungen erfordern manchmal ein partielles Laden. Einschränkungen beim Eager Loading begrenzen die abgerufenen Daten und vermeiden gleichzeitig 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'));
    }
}

Die Methoden withCount() und withSum() fügen Aggregationen hinzu, ohne vollständige Sammlungen zu laden, was sich ideal für Dashboard-Statistiken eignet.

Query Scopes für wiederverwendbare Abfragen

Query Scopes kapseln die Filterlogik innerhalb des Modells. Lokale Scopes bieten Flexibilität, während globale Scopes automatisch auf alle Abfragen angewendet werden.

Namenskonvention

Lokale Scopes verwenden den Präfix scope im Modell, werden aber ohne diesen aufgerufen: scopeActive() wird zu 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();

Die Verkettung von Scopes liefert lesbare und wartbare Abfragen, indem sie die Geschäftslogik im Modell zentralisiert.

Bereit für deine Laravel-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Accessoren und Mutatoren mit Attribute

Laravel 9+ führt eine einheitliche Syntax für Accessoren und Mutatoren über die Klasse Attribute ein. Dieser moderne Ansatz ersetzt die Methoden get*Attribute und set*Attribute.

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"

Die Methode shouldCache() optimiert teure Accessoren, indem sie mehrfache Neuberechnungen am gleichen Modell vermeidet.

Eigene Casts für komplexe Typen

Casts wandeln Werte automatisch zwischen PHP und der Datenbank um. Eigene Casts kapseln die Serialisierungslogik für Geschäftstypen.

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 in Kombination mit eigenen Casts gewährleisten die Integrität der Geschäftsdaten und behalten dabei eine elegante API bei.

Großvolumige Abfragen optimieren

Operationen über Millionen Datensätze erfordern spezifische Techniken, um den Speicher nicht zu erschöpfen. Chunking und Cursor verarbeiten die Daten in Stapeln.

Vorsicht beim Speicher

Model::all() lädt alle Datensätze in den Speicher. Bei einer Tabelle mit 100.000 Zeilen kann das mehrere Gigabyte RAM verbrauchen und die Anwendung abstürzen lassen.

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;
    }
}

Die Wahl zwischen chunk(), lazy() und cursor() hängt vom Anwendungsfall ab: chunk() für Änderungen, lazy() für Zwischenoperationen und cursor() für einfaches Lesen mit minimalem Speicherverbrauch.

Fortgeschrittene polymorphe Beziehungen

Polymorphe Beziehungen ermöglichen es einem Modell, über eine einzige Beziehung zu mehreren unterschiedlichen Modelltypen zu gehören. Diese Flexibilität eignet sich ideal für Kommentare, Tags oder angehängte Dateien.

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

Polymorphe Beziehungen vermeiden die Duplizierung von Tabellen und zentralisieren die Logik für übergreifende Funktionen.

Bereit für deine Laravel-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Traits und Observer für wiederverwendbare Logik

Traits kapseln wiederverwendbare Verhaltensweisen über Modelle hinweg. Observer zentralisieren Hooks auf Lebenszyklusereignisse.

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'];
}

Die boot*-Methoden der Traits werden während der Modellinitialisierung automatisch ausgeführt und ermöglichen so eine transparente Integration.

Fazit

Das Beherrschen von Eloquent ORM beruht auf dem Verständnis der zugrunde liegenden Mechanismen und der Anwendung passender Patterns. Die vorgestellten Techniken verwandeln naive Abfragen in performanten und wartbaren Code.

Eloquent-Optimierungs-Checkliste:

✅ Systematisch with() für angezeigte Beziehungen verwenden ✅ withCount() einsetzen, statt Sammlungen zum Zählen zu laden ✅ Filterlogik in Query Scopes kapseln ✅ Accessoren wiederholten Berechnungen in den Views vorziehen ✅ Eigene Casts für geschäftliche Value Objects implementieren ✅ chunk() oder lazy() für Operationen auf großen Tabellen verwenden ✅ Seiteneffekte in Observern zentralisieren ✅ Gemeinsame Verhaltensweisen in Traits auslagern

Die Werkzeuge php artisan telescope oder laravel-debugbar machen die generierten SQL-Abfragen sichtbar und decken fehlende Optimierungen auf.

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Tags

#laravel
#eloquent
#php
#orm
#database

Teilen

Verwandte Artikel