Eloquent ORM: wzorce i optymalizacje dla Laravela

Opanuj Eloquent ORM dzięki zaawansowanym wzorcom i technikom optymalizacji. Eager loading, query scopes, accessory, mutatory i wydajność dla aplikacji Laravel.

Wzorce i optymalizacje Eloquent ORM dla Laravela

Eloquent ORM zamienia interakcje z bazą danych w płynne i wyraziste operacje. Poza elegancką składnią, opanowanie zaawansowanych wzorców i technik optymalizacji decyduje o wydajności aplikacji Laravel na produkcji.

Najpierw wydajność

Problem N+1 to główna przyczyna spowolnień w aplikacjach Eloquent. Każda nieoptymalizowana relacja generuje dodatkowe zapytanie SQL na rekord.

Rozwiązanie problemu N+1 dzięki eager loading

Problem N+1 pojawia się, gdy każda iteracja po kolekcji wyzwala dodatkowe zapytanie ładujące relacje. Przy 100 artykułach i ich autorach to 101 zapytań zamiast jednego zoptymalizowanego.

Eager loading pobiera wszystkie relacje w maksymalnie jednym lub dwóch zapytaniach, drastycznie skracając czas odpowiedzi.

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 z with() przewiduje potrzeby i ładuje dane z wyprzedzeniem. Różnica w wydajności staje się spektakularna na dużych kolekcjach.

Eager loading warunkowy i ograniczony

Obszerne relacje czasem wymagają częściowego ładowania. Ograniczenia na eager loading limitują pobierane dane, jednocześnie unikając 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'));
    }
}

Metody withCount() i withSum() dodają agregacje bez ładowania pełnych kolekcji, co idealnie sprawdza się w statystykach dashboardu.

Query scopes dla zapytań wielokrotnego użytku

Query scopes hermetyzują logikę filtrowania wewnątrz modelu. Lokalne scopes oferują elastyczność, podczas gdy globalne stosują się automatycznie do wszystkich zapytań.

Konwencja nazewnictwa

Lokalne scopes używają prefiksu scope w modelu, ale są wywoływane bez niego: scopeActive() staje się 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();

Łączenie scopes w łańcuchy daje czytelne i łatwe w utrzymaniu zapytania, centralizując logikę biznesową w modelu.

Gotowy na rozmowy o Laravel?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Accessory i mutatory z Attribute

Laravel 9+ wprowadza ujednoliconą składnię dla accessorów i mutatorów za pomocą klasy Attribute. To nowoczesne podejście zastępuje metody get*Attribute i 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"

Metoda shouldCache() optymalizuje kosztowne accessory, unikając wielokrotnych przeliczeń tego samego modelu.

Niestandardowe casty dla złożonych typów

Casty automatycznie konwertują wartości między PHP a bazą danych. Niestandardowe casty hermetyzują logikę serializacji typów biznesowych.

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 połączone z niestandardowymi castami zapewniają integralność danych biznesowych przy zachowaniu eleganckiego API.

Optymalizacja zapytań na dużą skalę

Operacje na milionach rekordów wymagają specyficznych technik, aby uniknąć wyczerpania pamięci. Chunking i kursory przetwarzają dane partiami.

Uwaga na pamięć

Model::all() ładuje wszystkie rekordy do pamięci. Na tabeli z 100 000 wierszy może to zużyć kilka gigabajtów RAM i zawiesić aplikację.

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

Wybór między chunk(), lazy() i cursor() zależy od przypadku użycia: chunk() do modyfikacji, lazy() do operacji pośrednich, a cursor() do prostego odczytu z minimalnym zużyciem pamięci.

Zaawansowane relacje polimorficzne

Relacje polimorficzne pozwalają modelowi należeć do wielu różnych typów modeli za pomocą jednej relacji. Ta elastyczność idealnie sprawdza się dla komentarzy, tagów lub załączników.

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

Relacje polimorficzne unikają duplikacji tabel i centralizują logikę dla funkcji przekrojowych.

Gotowy na rozmowy o Laravel?

Ćwicz z naszymi interaktywnymi symulatorami, flashcards i testami technicznymi.

Trait-y i observery dla logiki wielokrotnego użytku

Trait-y hermetyzują wielokrotnie używane zachowania w modelach. Observery centralizują hooki na zdarzeniach cyklu życia.

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

Metody boot* trait-ów wykonują się automatycznie podczas inicjalizacji modelu, zapewniając przezroczystą integrację.

Podsumowanie

Opanowanie Eloquent ORM polega na zrozumieniu mechanizmów leżących u podstaw i stosowaniu odpowiednich wzorców. Przedstawione techniki przekształcają naiwne zapytania w wydajny i łatwy w utrzymaniu kod.

Lista kontrolna optymalizacji Eloquent:

✅ Systematyczne używanie with() dla wyświetlanych relacji ✅ Stosowanie withCount() zamiast ładowania kolekcji do liczenia ✅ Hermetyzacja logiki filtrowania w query scopes ✅ Preferowanie accessorów zamiast powtarzanych obliczeń w widokach ✅ Implementacja niestandardowych castów dla biznesowych Value Objects ✅ Używanie chunk() lub lazy() dla operacji na dużych tabelach ✅ Centralizacja efektów ubocznych w observerach ✅ Wyodrębnianie wspólnych zachowań do trait-ów

Narzędzia php artisan telescope lub laravel-debugbar pozwalają wizualizować generowane zapytania SQL i identyfikować brakujące optymalizacje.

Zacznij ćwiczyć!

Sprawdź swoją wiedzę z naszymi symulatorami rozmów i testami technicznymi.

Tagi

#laravel
#eloquent
#php
#orm
#database

Udostępnij

Powiązane artykuły