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 ofcount():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 singleWHERE 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.