Laravel Eloquent ORM: Advanced Techniques
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.
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 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:
<?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:
// 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:
<?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();
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.
<?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:
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 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:
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:
// 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:
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.
// 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:
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:
// 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:
// 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
// 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:
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
Raw Expressions & Advanced Queries
Sometimes you need more control than Eloquent's fluent methods provide:
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:
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 |
- 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!
