Laravel Eloquent ORM: Advanced Techniques
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.
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.
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:
<?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:
// 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:
<?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();
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.
<?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:
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
];
}
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:
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:
// 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:
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.
// 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.
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:
// 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.
// 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:
// 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:
Creating an Observer
<?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);
- 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:
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:
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 |
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:
- Global scopes on a small team are fine. On a larger codebase they turn into invisible
WHEREclauses that people forget about. I'd use them for real cross-cutting rules like tenancy, and use a local scope for anything else. - Observers hide side effects. Someone calls
$user->save()and an email goes out, which is a surprise in a seeder or an import script. For anything slow or external (emails, API calls), I'd dispatch a queued job or call a service explicitly from the action that needs it. - Polymorphic relations can't have real foreign keys, so the database won't stop orphaned rows. If only two parent types will ever exist, two plain foreign key columns are often simpler. If you do use them, register a
Relation::enforceMorphMap()so renaming a model class doesn't break the stored*_typevalues. - Eloquent itself isn't the right tool for heavy reporting or bulk imports of millions of rows. Hydrating a model per row is slow, and the query builder or a raw query with
DB::table()will be faster and use far less memory.
For everyday CRUD, though, scopes, casts, eager loading, and aggregates like withCount() are cheap to adopt and pay off right away.