Eloquent ORM
Backend

Laravel Eloquent ORM: Advanced Techniques

Mayur Dabhi
Mayur Dabhi
February 28, 2026
22 min read

A Laravel app usually starts out fine. Then the posts page takes two seconds to load, Debugbar shows 300 queries, and the same where('status', 'active') is copied into a dozen controllers. None of that is Eloquent's fault. It's what happens when you only use the basics.

This post covers the parts of Eloquent I reach for once an app grows: query scopes, the Attribute accessors, casts, through and polymorphic relationships, eager loading, subqueries and aggregates, chunking, observers, raw expressions, and factories. I'll also point out where each one can bite you.

Why it's worth learning

Eloquent is an Active Record implementation: each model instance is a row and knows how to save itself. That makes simple things very short to write, but it also makes it easy to fire queries without noticing. Most of what follows is about getting the convenience without the hidden cost.

Understanding the Eloquent Architecture

Each database table gets a model class, and that class is what your controllers talk to. Under the hood the model hands off to the query builder, which generates the SQL. This is the Active Record pattern, and knowing that the query builder sits underneath explains why almost every builder method also works on a model.

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

Requests go from the controller to the model, through the query builder, and down to the database

Query Scopes: Reusable Query Logic

If the same where clause shows up in three places, it belongs in a scope. Eloquent has two kinds: local scopes, which you call explicitly, and global scopes, which apply to every query on the model.

Local Scopes

A local scope is a model method prefixed with scope. You call it without the prefix:

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

They chain like any other builder method:

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 add a constraint to every query for a model. Soft deletes work this way, and you can write your own:

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: the active scope is applied automatically
$posts = Post::all(); // Only active posts

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

Global scopes affect all queries, including relationship queries and route model binding. The classic symptom is an admin panel returning a 404 for a record that's clearly in the database, because the scope filtered it out. Document them and make sure admin or reporting code calls withoutGlobalScope() where it needs to.

Accessors & Mutators (Attribute Casting)

Since Laravel 9 you define accessors and mutators with a single method that returns an Attribute, instead of separate getXAttribute/setXAttribute methods. The get closure runs when you read the value, set when you assign it.

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 the usual conversions (booleans, dates, JSON, enums) you don't need an accessor at all. A cast does it:

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

Use AsStringable::class, AsCollection::class, and AsArrayObject::class when you want objects back instead of plain strings or arrays. A gotcha with the plain array cast: $order->metadata['key'] = 'x' throws "Indirect modification of overloaded property". You have to copy the array, change it, and assign it back, or switch to AsArrayObject.

Advanced Relationships

Most apps get by on hasOne, hasMany, and belongsTo. These four cover the cases where those start to feel clumsy.

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

Tags or categories shared across model types

Has Many Through

Say you want every post written by users from a given country. Posts don't have a country_id, but users do, so you go through the users table:

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

When comments can attach to posts and videos, you don't want two comment tables. A polymorphic relation stores the parent's ID and its class in two columns (commentable_id and commentable_type), so one table serves both:

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)

Same idea for many-to-many. One tags table and one taggables pivot table can tag any model:

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

You load 10 posts, loop over them, and print $post->author->name. That's one query for the posts plus one per author: N+1. It's the most common performance problem I see in Laravel code, and eager loading fixes it.

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 2 queries instead of 11
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 so N+1 problems fail loudly in development. You'll see LazyLoadingViolationException: Attempted to lazy load [author] on model [App\Models\Post] but lazy loading is disabled. One thing to know: when you specify columns, as in with('author:id,name'), include the foreign and primary keys, or the relation silently comes back empty.

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

If you need one value from a related table (the latest comment date, say), a subquery pulls it in the same SQL statement instead of loading the whole relation:

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

User::all() on a table with a few hundred thousand rows will eventually hit "Allowed memory size exhausted". Process records in batches instead. If your callback updates a column you're filtering on, use chunkById(): plain chunk() uses offsets, so rows shift between pages and some get skipped.

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

To show a comment count or an order total next to each row, let the database do the math instead of loading the related models:

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 events as a model is created, updated, saved, deleted, and restored. An observer groups the handlers for one model in a single class:

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

Events only fire on model instances. Post::where(...)->update([...]) and $user->posts()->delete() run a single SQL statement, so no observers on Post fire. If a PostObserver needs to run, load the models and delete them one by one.

Raw Expressions & Advanced Queries

Sometimes the fluent methods can't express what you need, like a database function or a custom sort order. Raw methods let you drop to SQL. Keep user input in the bindings array, never concatenated into the string:

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

Factories with named states keep tests readable. User::factory()->admin() says what matters about the user, without a wall of array keys:

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();

Quick Reference: Do and Don't

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
Where to start

If you only do one thing from this post, turn on Model::preventLazyLoading() in development and fix what it throws. In most codebases that removes more wasted queries than anything else here.

When I'd skip these techniques

Not everything here is worth adding to every project. Here's where I'd hold back:

For everyday CRUD, though, scopes, casts, eager loading, and aggregates like withCount() are cheap to adopt and pay off right away.

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.