Master Advanced Laravel Eloquent

Unlock advanced Laravel Eloquent techniques: master relationships, eager loading, scopes, and performance tips with practical examples to optimize your queries.

Laravel Eloquent ORM Advanced: Relationships, N+1 Fixes, and Performance

By Elhassane Mehdioui, Full Stack Web Developer specialised in Laravel


Laravel Eloquent is one of the most expressive and developer-friendly ORMs available in any framework. But as applications grow, a shallow understanding of Eloquent quickly becomes a liability. Queries pile up, pages slow down, and databases groan under unnecessary load. In this guide, we go deep into Laravel Eloquent advanced patterns — covering every relationship type, solving the infamous Eloquent N+1 problem, leveraging scopes, observers, chunking, caching, and raw queries to achieve real Eloquent performance optimisation.


1. All Eloquent Relationship Types

Understanding the full spectrum of Eloquent relationships is the foundation of every well-structured Laravel application.

One-to-One

// User has one Profile
class User extends Model
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }
}

// Profile belongs to User
class Profile extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

One-to-Many

class Post extends Model
{
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}

Many-to-Many

class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
                    ->withPivot('assigned_at')
                    ->withTimestamps();
    }
}

Access pivot data via $user->roles->first()->pivot->assigned_at.

Has-Many-Through

This is useful when you want to access distant relations through an intermediate model.

// Country -> Users -> Posts
class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
    }
}

Polymorphic Relationships

Polymorphic relations let a model belong to more than one other model using a single association.

class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

class Post extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

class Video extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

Many-to-Many Polymorphic

class Tag extends Model
{
    public function posts(): MorphedByMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }
}

2. The N+1 Problem and How to Solve It with Eager Loading

The Eloquent N+1 problem is the single most common performance pitfall in Laravel applications. It occurs when you load a collection and then access a relationship inside a loop, triggering one query per item.

The Problem

// This fires 1 query for posts + 1 query PER post for the author = N+1
$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author->name; // triggers a new query each iteration
}

With 100 posts, that is 101 queries. With 1,000 posts, it is 1,001 queries.

The Fix: with()

// 2 queries total, regardless of how many posts exist
$posts = Post::with('author')->get();

foreach ($posts as $post) {
    echo $post->author->name; // no extra query — already loaded
}

Nested Eager Loading

$posts = Post::with('author.profile', 'comments.user')->get();

Lazy Eager Loading

When you have already loaded a collection but realise you need a relationship:

$posts = Post::all();

// Load the relationship after the fact
$posts->load('author');

Detecting N+1 in Development

Use the preventLazyLoading method in your AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

This throws a LazyLoadingViolationException whenever a lazy load occurs outside production — catching bugs before they reach your users.


3. withCount, withMin, withMax, withSum, withAvg

Instead of loading entire relationships just to count them, use aggregate eager loading:

// Adds a comments_count attribute without loading all comments
$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;
}

// You can also use conditional counts
$posts = Post::withCount([
    'comments',
    'comments as approved_comments_count' => fn ($q) => $q->where('approved', true),
])->get();

// Aggregates
$users = User::withSum('orders', 'total')
             ->withAvg('reviews', 'rating')
             ->get();

4. Global and Local Scopes

Scopes let you encapsulate reusable query constraints directly on the model, keeping your controllers thin and your queries expressive.

Local Scopes

class Post extends Model
{
    public function scopePublished(Builder $query): Builder
    {
        return $query->where('status', 'published');
    }

    public function scopePopular(Builder $query, int $threshold = 100): Builder
    {
        return $query->where('views', '>=', $threshold);
    }
}

// Usage
$posts = Post::published()->popular(500)->latest()->get();

Global Scopes

Global scopes apply automatically to every query on a model — similar to how SoftDeletes works.

class ActiveScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('active', true);
    }
}

class User extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new ActiveScope());
    }
}

// To remove the global scope for a specific query:
User::withoutGlobalScope(ActiveScope::class)->get();

5. Model Observers

Observers decouple your event-handling logic from your controllers and models. They react to Eloquent lifecycle events: creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored.

php artisan make:observer PostObserver --model=Post
class PostObserver
{
    public function creating(Post $post): void
    {
        $post->slug = Str::slug($post->title);
    }

    public function created(Post $post): void
    {
        Cache::tags(['posts'])->flush();
        NotifySubscribersJob::dispatch($post);
    }

    public function deleting(Post $post): void
    {
        // Clean up related media files before deletion
        $post->media()->delete();
    }
}

Register the observer in your service provider:

public function boot(): void
{
    Post::observe(PostObserver::class);
}

6. Chunking Large Datasets

Loading thousands of records at once will exhaust memory quickly. Eloquent provides several chunking strategies.

chunk()

// Processes 200 records at a time
Post::where('status', 'draft')->chunk(200, function ($posts) {
    foreach ($posts as $post) {
        ProcessDraftJob::dispatch($post);
    }
});

chunkById()

Safer than chunk() when modifying records during iteration, because it uses the primary key as a cursor rather than an offset:

Post::where('needs_update', true)->chunkById(500, function ($posts) {
    foreach ($posts as $post) {
        $post->update(['processed' => true]);
    }
});

lazy() and lazyById()

Returns a LazyCollection, ideal for pipelines:

Post::where('status', 'published')
    ->lazyById(500)
    ->each(function (Post $post) {
        GenerateSitemapEntry::dispatch($post);
    });

7. Query Caching

Eloquent does not cache queries by default, but Laravel's cache layer makes it straightforward to add.

Manual Caching with remember()

$posts = Cache::remember('published_posts', now()->addMinutes(10), function () {
    return Post::with('author')->published()->latest()->get();
});

Cache Tags for Grouped Invalidation

If your cache driver supports tags (Redis, Memcached):

$posts = Cache::tags(['posts', 'homepage'])->remember('featured_posts', 300, function () {
    return Post::with('author')->where('featured', true)->take(6)->get();
});

// Invalidate all post-related cache at once
Cache::tags(['posts'])->flush();

Caching Individual Models

public static function findCached(int $id): self
{
    return Cache::remember("post_{$id}", 600, fn () => static::findOrFail($id));
}

8. Raw Queries and Expressions

Sometimes Eloquent's query builder cannot express the exact SQL you need. Raw expressions give you an escape hatch without abandoning the fluent interface.

selectRaw()

$stats = Order::selectRaw('
    DATE(created_at) as date,
    COUNT(*) as total_orders,
    SUM(amount) as revenue
')
->whereBetween('created_at', [$start, $end])
->groupByRaw('DATE(created_at)')
->orderByRaw('DATE(created_at) DESC')
->get();

whereRaw()

$users = User::whereRaw('LOWER(email) = ?', [strtolower($email)])->first();

DB::statement() for DDL or Complex Queries

DB::statement('ALTER TABLE posts ADD FULLTEXT INDEX ft_content (title, body)');

Binding Parameters Safely

Always pass user input as bindings, never interpolate directly into the SQL string:

// Safe
User::whereRaw('created_at > ?', [$date])->get();

// Never do this
User::whereRaw("created_at > '{$date}'")->get(); // SQL injection risk

9. Bonus: Query Optimisation Tips

  • Select only what you need: Post::select('id', 'title', 'slug')->get() avoids loading large text columns you do not use.
  • Index your foreign keys and filter columns: No amount of Eloquent magic compensates for a missing index.
  • Use exists() instead of count(): Post::where('user_id', $id)->exists() stops at the first matching row.
  • Avoid all() in production: Always scope, limit, or paginate. Post::all() on a million-row table will kill your server.
  • Use find() with arrays for batch lookups: User::find([1, 2, 3]) runs a single WHERE id IN (...) query.

Conclusion

Mastering Laravel Eloquent advanced techniques is not about memorising methods — it is about understanding what SQL your code produces and making deliberate choices. Solving the Eloquent N+1 problem with with() alone can reduce query counts from hundreds to two. Combining eager loading with scopes, observers, chunking, caching, and raw expressions gives you the tools to build Laravel applications that remain fast and maintainable at any scale.

The next time you open Telescope or Debugbar and see 200 queries on a single page load, you will know exactly where to look — and how to fix it.


Elhassane Mehdioui is a Full Stack Web Developer specialised in Laravel, building high-performance web applications and APIs.

Need the full picture?

Full-stack applications from database to UI.

MERN stack, Laravel + React, Spring Boot — complete solutions built end-to-end.

HassanOSSYS-00
SECURE CHANNEL · ACTIVE
INIT://