Eloquent ORM
Backend

Laravel Eloquent ORM: Advanced Techniques

Mayur Dabhi
Mayur Dabhi
February 28, 2026
22 min read

Laravel's Eloquent ORM is one of the most powerful features of the framework, providing an elegant, expressive way to interact with your database. While the basics of Eloquent are straightforward, mastering its advanced features can dramatically improve your application's performance, maintainability, and code quality.

In this comprehensive guide, we'll explore advanced Eloquent techniques that will take your Laravel development skills to the next level. From query scopes and accessors to polymorphic relationships and performance optimization, you'll learn how to leverage Eloquent like a pro.

Why Master Eloquent?

Eloquent is more than just an ORM—it's a complete Active Record implementation that makes database interactions feel natural and expressive. Advanced Eloquent knowledge separates junior developers from senior Laravel engineers.

Understanding the Eloquent Architecture

Before diving into advanced techniques, let's understand how Eloquent works under the hood. Eloquent follows the Active Record pattern, where each database table has a corresponding "Model" class that interacts with that table.

Eloquent Architecture Flow Controller Request Handler Eloquent Model Active Record Query Builder SQL Generator Database MySQL/PostgreSQL Model Features • Relationships • Query Scopes • Accessors/Mutators • Events/Observers • Eager Loading • Casts • Factories

Eloquent bridges your application logic and database through an elegant Active Record implementation

Query Scopes: Reusable Query Logic

Query scopes allow you to encapsulate common query constraints into reusable methods. There are two types: local scopes and global scopes.

Local Scopes

Local scopes let you define common sets of constraints that can be easily reused throughout your application. They're prefixed with scope:

User.php
<?php

namespace App\Models;

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

class User extends Model
{
    // Simple local scope
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', 'active');
    }

    // Local scope with parameters
    public function scopeOfType(Builder $query, string $type): Builder
    {
        return $query->where('type', $type);
    }

    // Complex scope combining multiple conditions
    public function scopePopular(Builder $query, int $minPosts = 10): Builder
    {
        return $query->where('posts_count', '>=', $minPosts)
                    ->where('followers_count', '>=', 100);
    }

    // Date-based scope
    public function scopeCreatedBetween(
        Builder $query, 
        string $start, 
        string $end
    ): Builder {
        return $query->whereBetween('created_at', [$start, $end]);
    }
}

Now you can chain these scopes elegantly:

Usage Examples
// Simple scope usage
$activeUsers = User::active()->get();

// Chaining multiple scopes
$popularAdmins = User::active()
    ->ofType('admin')
    ->popular(20)
    ->get();

// Combining with other query methods
$recentActiveUsers = User::active()
    ->createdBetween('2024-01-01', '2024-12-31')
    ->orderBy('created_at', 'desc')
    ->paginate(15);

Global Scopes

Global scopes apply constraints to all queries for a model. The classic example is soft deletes, but you can create custom ones:

ActiveScope.php
<?php

namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

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

// In your Model
class Post extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new ActiveScope);
    }
}

// Usage - Active scope is automatically applied
$posts = Post::all(); // Only active posts

// Remove scope for specific query
$allPosts = Post::withoutGlobalScope(ActiveScope::class)->get();
Warning

Be careful with global scopes! They affect all queries, including relationships. Always document them well and provide a way to bypass them when needed.

Accessors & Mutators (Attribute Casting)

Laravel 9+ introduced a cleaner way to define accessors and mutators using the Attribute class. This allows you to transform attribute values when getting or setting them.

Modern Accessors & Mutators
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    // Simple accessor - transforms when reading
    protected function firstName(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => ucfirst($value),
        );
    }

    // Accessor with mutator - transforms both ways
    protected function email(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => strtolower($value),
            set: fn (string $value) => strtolower($value),
        );
    }

    // Computed attribute from multiple columns
    protected function fullName(): Attribute
    {
        return Attribute::make(
            get: fn () => "{$this->first_name} {$this->last_name}",
        );
    }

    // Accessor with caching (computed once per instance)
    protected function profileUrl(): Attribute
    {
        return Attribute::make(
            get: fn () => route('profile.show', $this->slug),
        )->shouldCache();
    }

    // JSON accessor for complex data
    protected function settings(): Attribute
    {
        return Attribute::make(
            get: fn (?string $value) => json_decode($value ?? '{}', true),
            set: fn (array $value) => json_encode($value),
        );
    }
}

Attribute Casting

For common transformations, use the built-in casting system:

Model Casts
class Order extends Model
{
    protected $casts = [
        'amount' => 'decimal:2',
        'is_paid' => 'boolean',
        'metadata' => 'array',
        'shipped_at' => 'datetime',
        'options' => 'collection',
        'status' => OrderStatus::class, // Enum casting (PHP 8.1+)
        'address' => AddressCast::class, // Custom cast
    ];
}
Pro Tip

Use AsStringable::class, AsCollection::class, and AsArrayObject::class casts to get fluent, chainable objects back from your database columns.

Advanced Relationships

While basic relationships (hasOne, hasMany, belongsTo) are common knowledge, Eloquent offers powerful advanced relationship types.

Has One Through

Access distant relations through intermediary models

Has Many Through

Access collections through intermediate relations

Polymorphic

Single relation to multiple model types

Many-to-Many Polymorphic

Complex tagging and categorization systems

Has Many Through

Access distant relationships through an intermediate model:

Has Many Through Relationship Country id, name User id, country_id Post id, user_id hasManyThrough
Has Many Through Example
class Country extends Model
{
    // Get all posts for the country through users
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(
            Post::class,      // Final model
            User::class,      // Intermediate model
            'country_id',     // Foreign key on User
            'user_id',        // Foreign key on Post
            'id',             // Local key on Country
            'id'              // Local key on User
        );
    }
}

// Usage
$country = Country::find(1);
$posts = $country->posts; // All posts by users in this country

Polymorphic Relationships

Polymorphic relationships allow a model to belong to more than one type of model using a single association:

Polymorphic One-to-Many
// Comments can belong to Posts or Videos
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');
    }
}

// Usage
$post->comments()->create(['body' => 'Great post!']);
$video->comments()->create(['body' => 'Nice video!']);

// Get the parent model
$comment = Comment::find(1);
$parent = $comment->commentable; // Returns Post or Video

Many-to-Many Polymorphic (Tags Example)

Perfect for tagging systems where multiple model types can share tags:

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

    public function videos(): MorphToMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

class Post extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

// Usage
$post->tags()->attach([1, 2, 3]);
$tag->posts; // All posts with this tag
$tag->videos; // All videos with this tag

Eager Loading & N+1 Prevention

The N+1 query problem is one of the most common performance issues in Laravel applications. Eager loading is your solution.

N+1 Problem vs Eager Loading ❌ N+1 Problem (11 queries) SELECT * FROM posts SELECT * FROM users WHERE id = 1 SELECT * FROM users WHERE id = 2 ... repeated for each post ... SELECT * FROM users WHERE id = 10 ✅ Eager Loading (2 queries) SELECT * FROM posts Query 1 SELECT * FROM users WHERE id IN (1, 2, 3, ...10) Query 2 ~80% fewer queries!
Eager Loading Techniques
// Basic eager loading
$posts = Post::with('author')->get();

// Multiple relationships
$posts = Post::with(['author', 'comments', 'tags'])->get();

// Nested eager loading
$posts = Post::with('author.profile')->get();

// Eager load with constraints
$posts = Post::with(['comments' => function ($query) {
    $query->where('approved', true)
          ->orderBy('created_at', 'desc')
          ->limit(5);
}])->get();

// Eager load specific columns (memory optimization)
$posts = Post::with('author:id,name,email')->get();

// Nested with constraints
$posts = Post::with([
    'author' => fn($q) => $q->select('id', 'name'),
    'comments' => fn($q) => $q->latest()->limit(3),
    'comments.user:id,name,avatar',
])->get();

// Load missing relationships (after initial query)
$posts = Post::all();
$posts->load('author'); // Loads if not already loaded
$posts->loadMissing('comments'); // Only loads if missing

Prevent N+1 Automatically

Add this to your AppServiceProvider to catch N+1 problems during development:

AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Throw exception on lazy loading (development only)
    Model::preventLazyLoading(! app()->isProduction());
    
    // Or log instead of throwing
    Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
        logger()->warning("Lazy loading {$relation} on {$model}");
    });
}

Query Optimization Techniques

Using Subqueries

Subqueries allow you to include computed values without additional queries:

Subqueries
// Add latest comment date as a subquery
$posts = Post::addSelect([
    'latest_comment_at' => Comment::select('created_at')
        ->whereColumn('post_id', 'posts.id')
        ->latest()
        ->limit(1)
])->get();

// Sort by subquery
$users = User::orderByDesc(
    Post::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()
        ->limit(1)
)->get();

// Filter by subquery existence
$activeAuthors = User::whereHas('posts', function ($query) {
    $query->where('created_at', '>=', now()->subMonth());
})->get();

Chunking for Large Datasets

When processing thousands of records, use chunking to prevent memory exhaustion:

Chunking Methods
// Process 100 records at a time
User::chunk(100, function ($users) {
    foreach ($users as $user) {
        $user->sendNewsletter();
    }
});

// Chunk by ID (more reliable for large datasets)
User::chunkById(100, function ($users) {
    // IDs ensure no records are skipped during updates
    $users->each->deactivate();
});

// Lazy loading with cursor (memory efficient)
foreach (User::cursor() as $user) {
    // Hydrates one model at a time
    process($user);
}

// Lazy collection (best for huge datasets)
User::lazy()->each(function ($user) {
    // Automatically chunks under the hood
    $user->updateStats();
});

Using withCount and withSum

Aggregate Functions
// Count related records
$posts = Post::withCount('comments')->get();
// Access: $post->comments_count

// Conditional count
$posts = Post::withCount([
    'comments',
    'comments as approved_comments_count' => function ($query) {
        $query->where('approved', true);
    },
])->get();

// Sum, Avg, Min, Max
$users = User::withSum('orders', 'total')->get();
// Access: $user->orders_sum_total

$users = User::withAvg('orders', 'total')
    ->withMax('orders', 'total')
    ->withMin('orders', 'total')
    ->get();

Model Events & Observers

Eloquent fires several events during a model's lifecycle, allowing you to hook into various stages:

Eloquent Model Lifecycle Events creating created updating updated saving saved deleting deleted restoring restored *-ing events fire BEFORE action | *-ed events fire AFTER action Return false from *-ing event to cancel the operation

Creating an Observer

UserObserver.php
<?php

namespace App\Observers;

use App\Models\User;
use Illuminate\Support\Str;

class UserObserver
{
    public function creating(User $user): void
    {
        $user->uuid = Str::uuid();
        $user->api_token = Str::random(60);
    }

    public function created(User $user): void
    {
        // Send welcome email
        $user->sendWelcomeEmail();
        
        // Create default settings
        $user->settings()->create([
            'theme' => 'dark',
            'notifications' => true,
        ]);
    }

    public function updating(User $user): bool
    {
        // Prevent email changes for verified users
        if ($user->isDirty('email') && $user->email_verified_at) {
            return false; // Cancel update
        }
        return true;
    }

    public function deleted(User $user): void
    {
        // Clean up related data
        $user->posts()->delete();
        Storage::deleteDirectory("users/{$user->id}");
    }
}

// Register in AppServiceProvider
User::observe(UserObserver::class);
When to Use Observers
  • Generating UUIDs or slugs automatically
  • Sending notifications on model changes
  • Syncing data with external services
  • Audit logging
  • Cleaning up related data on delete

Raw Expressions & Advanced Queries

Sometimes you need more control than Eloquent's fluent methods provide:

Raw Expressions
use Illuminate\Support\Facades\DB;

// Raw select
$users = User::select([
    'id', 'name',
    DB::raw('YEAR(created_at) as join_year'),
    DB::raw('DATEDIFF(NOW(), last_login_at) as days_inactive'),
])->get();

// Raw where
$posts = Post::whereRaw(
    'LOWER(title) LIKE ?', 
    ['%' . strtolower($search) . '%']
)->get();

// Raw order
$products = Product::orderByRaw(
    'CASE WHEN featured = 1 THEN 0 ELSE 1 END, price ASC'
)->get();

// Raw having
$categories = Category::select('category_id')
    ->groupBy('category_id')
    ->havingRaw('COUNT(*) > ?', [10])
    ->get();

// Full raw query with binding
$results = DB::select(
    'SELECT * FROM users WHERE active = ? AND created_at > ?',
    [true, now()->subMonth()]
);

Testing with Eloquent

Use factories and seeders to create test data efficiently:

UserFactory.php
class UserFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
            'password' => Hash::make('password'),
            'email_verified_at' => now(),
        ];
    }

    // State modifiers
    public function admin(): static
    {
        return $this->state(['role' => 'admin']);
    }

    public function unverified(): static
    {
        return $this->state(['email_verified_at' => null]);
    }

    public function withPosts(int $count = 3): static
    {
        return $this->has(Post::factory()->count($count));
    }
}

// Usage in tests
$user = User::factory()->admin()->withPosts(5)->create();

$users = User::factory()
    ->count(10)
    ->has(Order::factory()->count(3))
    ->create();

Best Practices Summary

Practice Do Don't
Loading Relations Use eager loading with with() Access relations in loops without preloading
Querying Use query scopes for reusable logic Duplicate where clauses everywhere
Large Datasets Use chunk() or cursor() Load everything with all()
Counting Use withCount() Call count() on loaded collections
Model Logic Use accessors, mutators, and observers Transform data in controllers
Testing Use factories with states Create models with raw arrays
Key Takeaways
  • Query Scopes keep your queries DRY and readable
  • Accessors/Mutators centralize data transformation
  • Eager Loading eliminates N+1 queries
  • Polymorphic Relations enable flexible data models
  • Observers handle side effects cleanly
  • Chunking prevents memory issues with large datasets

Conclusion

Mastering Eloquent's advanced features transforms how you build Laravel applications. These techniques—from query scopes and eager loading to polymorphic relationships and model events—enable you to write cleaner, faster, and more maintainable code.

Start incorporating these patterns into your projects gradually. Begin with eager loading to fix N+1 issues, then move on to query scopes and accessors. Before you know it, these advanced techniques will become second nature.

The key to mastery is practice. Take one technique at a time, implement it in a real project, and watch your Laravel skills soar. Happy coding!

Laravel Eloquent ORM PHP Database Performance
Mayur Dabhi

Mayur Dabhi

Full Stack Developer specializing in Laravel, React, and modern web technologies. Passionate about clean code and elegant solutions.