I've been writing Laravel applications for quite some time now, and if there's one thing I've learned, it's that most developers barely scratch the surface of what Eloquent can do. We learn the basics, we get comfortable with where() and with(), maybe throw in a relationship or two, and we call it a day. But Eloquent has so much more to offer, and some of its most powerful features are hiding in plain sight.
This article is a collection of patterns and techniques I've picked up over years of building production applications. Some of these I discovered by accident, others by necessity when I hit a wall and had to dig deeper. None of this is theoretical fluff. These are battle-tested patterns that have saved me countless hours and helped me build more maintainable codebases.
You probably know about query scopes. You've likely written a few scopeActive() or scopePublished() methods in your models. But have you thought about composing them in more sophisticated ways?
The typical scope looks something like this:
1class Post extends Model 2{ 3 public function scopePublished(Builder $query): Builder 4 { 5 return $query->whereNotNull('published_at') 6 ->where('published_at', '<=', now()); 7 } 8 9 public function scopeFeatured(Builder $query): Builder10 {11 return $query->where('is_featured', true);12 }13}
Nothing wrong with that. But here's where it gets interesting. You can create dynamic scopes that accept other scopes as arguments, building a more flexible query system.
1class Post extends Model 2{ 3 public function scopeApplyFilters(Builder $query, array $filters): Builder 4 { 5 foreach ($filters as $filter) { 6 if (method_exists($this, 'scope' . ucfirst($filter))) { 7 $query->{$filter}(); 8 } 9 }10 11 return $query;12 }13}14 15// Usage16$posts = Post::applyFilters(['published', 'featured'])->get();
But let's go further. What if you want scopes that can be conditionally applied based on runtime values, without cluttering your controller with if statements?
1class Post extends Model 2{ 3 public function scopeWhenPublished(Builder $query, bool $apply = true): Builder 4 { 5 return $query->when($apply, fn($q) => $q->published()); 6 } 7 8 public function scopeForUser(Builder $query, ?User $user): Builder 9 {10 if (!$user) {11 return $query->published();12 }13 14 if ($user->isAdmin()) {15 return $query;16 }17 18 return $query->where(function ($q) use ($user) {19 $q->published()20 ->orWhere('author_id', $user->id);21 });22 }23}
The scopeForUser pattern is particularly powerful. It encapsulates authorization logic directly in your model, keeping your controllers clean. The query automatically adjusts based on who's viewing it.
Now here's a pattern I don't see used enough: scope objects. Instead of defining all your scopes as methods on the model, you can extract them into dedicated classes.
1class PublishedScope 2{ 3 public function __invoke(Builder $query, Carbon $asOf = null): Builder 4 { 5 $asOf = $asOf ?? now(); 6 7 return $query->whereNotNull('published_at') 8 ->where('published_at', '<=', $asOf); 9 }10}11 12class Post extends Model13{14 public function scopePublished(Builder $query, Carbon $asOf = null): Builder15 {16 return (new PublishedScope())($query, $asOf);17 }18}
Why bother? Because now you can test PublishedScope in isolation, reuse it across multiple models, and keep your model files from becoming thousand-line monsters. When you have a dozen scopes with complex logic, this separation becomes invaluable.
Laravel's custom casts are criminally underused. Most tutorials show you how to cast a JSON column to an array and leave it at that. But casts can do so much more.
Let's start with something practical: encrypted attributes that are transparent to your application code.
1class EncryptedCast implements CastsAttributes 2{ 3 public function get($model, string $key, $value, array $attributes) 4 { 5 if (is_null($value)) { 6 return null; 7 } 8 9 try {10 return decrypt($value);11 } catch (DecryptException $e) {12 // Log this, the data might be corrupted13 report($e);14 return null;15 }16 }17 18 public function set($model, string $key, $value, array $attributes)19 {20 if (is_null($value)) {21 return null;22 }23 24 return encrypt($value);25 }26}
Apply it to your model:
1class User extends Model2{3 protected $casts = [4 'social_security_number' => EncryptedCast::class,5 'api_secret' => EncryptedCast::class,6 ];7}
Now every time you access $user->social_security_number, it's automatically decrypted. When you save it, it's encrypted. Your application code doesn't need to know or care about the encryption.
But we can make this more interesting. What about a cast that handles money with proper precision?
1class MoneyCast implements CastsAttributes 2{ 3 public function __construct( 4 private string $currency = 'USD' 5 ) {} 6 7 public function get($model, string $key, $value, array $attributes) 8 { 9 if (is_null($value)) {10 return null;11 }12 13 return new Money((int) $value, new Currency($this->currency));14 }15 16 public function set($model, string $key, $value, array $attributes)17 {18 if (is_null($value)) {19 return null;20 }21 22 if ($value instanceof Money) {23 return $value->getAmount();24 }25 26 // Assume cents if integer, dollars if float27 if (is_float($value)) {28 return (int) ($value * 100);29 }30 31 return (int) $value;32 }33}
The cast accepts parameters, so you can do:
1protected $casts = [2 'price' => MoneyCast::class . ':USD',3 'cost_in_euros' => MoneyCast::class . ':EUR',4];
Here's another pattern I love: casts that validate data on the way in.
1class EmailCast implements CastsAttributes 2{ 3 public function get($model, string $key, $value, array $attributes) 4 { 5 return $value; 6 } 7 8 public function set($model, string $key, $value, array $attributes) 9 {10 if (is_null($value)) {11 return null;12 }13 14 $value = strtolower(trim($value));15 16 if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {17 throw new InvalidArgumentException("Invalid email address: {$value}");18 }19 20 return $value;21 }22}
This ensures that any email stored through Eloquent is always lowercase, trimmed, and valid. You catch data issues at the source rather than finding out later that you have garbage in your database.
What about casts that depend on other attributes? Say you have a polymorphic-style situation where one column determines how another should be interpreted.
1class DynamicSettingCast implements CastsAttributes 2{ 3 public function get($model, string $key, $value, array $attributes) 4 { 5 $type = $attributes['type'] ?? 'string'; 6 7 return match ($type) { 8 'boolean' => (bool) $value, 9 'integer' => (int) $value,10 'json' => json_decode($value, true),11 'encrypted' => decrypt($value),12 default => $value,13 };14 }15 16 public function set($model, string $key, $value, array $attributes)17 {18 $type = $attributes['type'] ?? 'string';19 20 return match ($type) {21 'boolean' => $value ? '1' : '0',22 'integer' => (string) $value,23 'json' => json_encode($value),24 'encrypted' => encrypt($value),25 default => $value,26 };27 }28}
Now a single settings table can store different types of values, and Eloquent handles the conversion automatically based on the type column.
Everyone knows the basic polymorphic relationship setup. A Comment can belong to a Post or a Video. Standard stuff. But there are edge cases and advanced patterns that trip people up.
First, let's talk about the custom morph map, which you should always use in production applications.
1// In a service provider2Relation::enforceMorphMap([3 'post' => Post::class,4 'video' => Video::class,5 'user' => User::class,6]);
Without this, Laravel stores the full class name in your database: App\Models\Post. Refactor your namespace? Rename a model? You now have orphaned records pointing to classes that don't exist. The morph map decouples your database from your class names.
But here's a pattern most people miss: polymorphic relationships with additional constraints.
1class Comment extends Model 2{ 3 public function commentable() 4 { 5 return $this->morphTo(); 6 } 7} 8 9class Post extends Model10{11 public function comments()12 {13 return $this->morphMany(Comment::class, 'commentable');14 }15 16 public function approvedComments()17 {18 return $this->morphMany(Comment::class, 'commentable')19 ->where('approved', true);20 }21 22 public function commentsWithReplies()23 {24 return $this->morphMany(Comment::class, 'commentable')25 ->with('replies')26 ->whereNull('parent_id');27 }28}
You can chain anything onto a relationship definition. This keeps your controllers clean and your queries consistent.
What about when you need to eager load different relationships based on the morph type? This comes up more often than you'd think.
1$comments = Comment::with(['commentable' => function (MorphTo $morphTo) {2 $morphTo->morphWith([3 Post::class => ['author', 'category'],4 Video::class => ['channel', 'thumbnails'],5 ]);6}])->get();
Each morph type gets its own eager loading configuration. Posts load their author and category, videos load their channel and thumbnails. One query, no N+1 problems, clean code.
Here's another edge case: what if the morphed model has been deleted? By default, you get null, which can cause errors if you're not careful.
1class Comment extends Model 2{ 3 public function commentable() 4 { 5 return $this->morphTo()->withDefault(function ($model, $comment) { 6 // Return a null object pattern 7 return new class extends Model { 8 public $title = '[Deleted]'; 9 public $exists = false;10 11 public function getRouteKey()12 {13 return null;14 }15 };16 });17 }18}
Now $comment->commentable->title always returns something safe, even if the original post was deleted. No more null checks scattered throughout your views.
Most Laravel developers know about paginate(). Fewer know about cursorPaginate(). Even fewer understand when to use which.
Standard offset pagination works like this: "Give me 15 records, skip the first 30." For page 3, it executes something like SELECT * FROM posts LIMIT 15 OFFSET 30. The problem? As your offset grows, the database has to scan through all those skipped records. Page 1000 means scanning through 15,000 records just to throw most of them away.
Cursor pagination works differently. It says "Give me 15 records after this specific one." It uses a WHERE clause instead of an offset: SELECT * FROM posts WHERE id > 12345 LIMIT 15. The database can use an index to jump directly to the right spot. No scanning, consistent performance regardless of which "page" you're on.
1// Standard pagination (slows down on later pages)2$posts = Post::orderBy('created_at', 'desc')3 ->paginate(15);4 5// Cursor pagination (consistent performance)6$posts = Post::orderBy('created_at', 'desc')7 ->cursorPaginate(15);
But cursor pagination has tradeoffs. You can't jump to page 50 directly. Users can only go forward and backward. And the cursor is based on the sort order, so you need to be careful.
Here's where it gets tricky. Cursor pagination requires a deterministic sort order. If two records have the same created_at value, the cursor might skip or duplicate records. Always include a unique column as a tiebreaker.
1$posts = Post::orderBy('created_at', 'desc')2 ->orderBy('id', 'desc')3 ->cursorPaginate(15);
Now the sort is deterministic. Even if two posts were created at the exact same microsecond, their IDs will break the tie.
For APIs that power infinite scroll, cursor pagination is almost always the right choice. But there's another use case: background jobs that process large datasets.
1class ProcessAllUsers implements ShouldQueue 2{ 3 public function handle() 4 { 5 User::query() 6 ->orderBy('id') 7 ->cursor() 8 ->each(function (User $user) { 9 // Process each user10 // Memory usage stays flat11 });12 }13}
Wait, that's cursor(), not cursorPaginate(). Different thing entirely. The cursor() method returns a lazy collection that only loads one record at a time. It uses a database cursor under the hood, keeping memory usage constant even when processing millions of records.
Compare this to get():
1// Don't do this with large datasets2User::all()->each(fn($user) => $this->process($user));3// Loads ALL users into memory at once4 5// Do this instead6User::cursor()->each(fn($user) => $this->process($user));7// One record in memory at a time
The gotcha with database cursors is that they keep a database connection open for the entire operation. If you're processing millions of records, that connection is tied up for potentially hours. Some database configurations don't play well with this. MySQL's wait_timeout might kill your connection partway through.
For very large datasets, chunking might be more appropriate:
1User::orderBy('id')->chunk(1000, function ($users) {2 foreach ($users as $user) {3 $this->process($user);4 }5});
This loads 1000 records at a time, processes them, then loads the next 1000. More memory than cursor(), but no long-running database connection.
I love Eloquent. It makes 90% of database work pleasant and maintainable. But that other 10%? Sometimes Eloquent gets in the way, and knowing when to drop down to raw queries is an important skill.
The first scenario: bulk operations. Say you need to update 100,000 records.
1// Don't do this2User::where('last_login_at', '<', now()->subYear())3 ->get()4 ->each(function ($user) {5 $user->update(['status' => 'inactive']);6 });
This fires 100,000 individual UPDATE queries. It also triggers model events, updates timestamps, and loads every record into memory. If you don't need any of that, skip it:
1// Do this instead2User::where('last_login_at', '<', now()->subYear())3 ->update(['status' => 'inactive']);
One query. Done. No models instantiated, no events fired. If you need events, fire them manually after the bulk update.
But sometimes even that's not enough. Complex updates that Eloquent can't express require raw SQL:
1DB::statement(' 2 UPDATE users 3 SET subscription_tier = CASE 4 WHEN total_spent >= 10000 THEN "platinum" 5 WHEN total_spent >= 5000 THEN "gold" 6 WHEN total_spent >= 1000 THEN "silver" 7 ELSE "bronze" 8 END 9 WHERE total_spent > 010');
Eloquent can't do CASE expressions in updates. You could loop through and update individually, but why? One query handles it all.
Here's another scenario: complex aggregations and reports. Eloquent is great for CRUD. It's less great for analytics.
1// Trying to force this through Eloquent is painful 2$stats = DB::select(' 3 SELECT 4 DATE(created_at) as date, 5 COUNT(*) as total_orders, 6 SUM(total) as revenue, 7 AVG(total) as average_order, 8 COUNT(DISTINCT user_id) as unique_customers 9 FROM orders10 WHERE created_at >= ?11 GROUP BY DATE(created_at)12 ORDER BY date DESC13', [now()->subDays(30)]);
Could you do this with Eloquent's query builder? Probably. Would it be readable? Definitely not. Raw SQL shines for reporting queries.
The same goes for complex joins across multiple tables. When you're joining five tables with subqueries, the query builder syntax becomes a liability. Write the SQL directly, make sure your parameters are bound properly, and move on.
1$results = DB::select(' 2 SELECT 3 p.name as product_name, 4 c.name as category_name, 5 COUNT(DISTINCT o.id) as order_count, 6 SUM(oi.quantity) as units_sold 7 FROM products p 8 JOIN categories c ON p.category_id = c.id 9 JOIN order_items oi ON oi.product_id = p.id10 JOIN orders o ON oi.order_id = o.id11 WHERE o.created_at BETWEEN ? AND ?12 AND o.status = ?13 GROUP BY p.id, c.id14 HAVING units_sold > ?15 ORDER BY units_sold DESC16 LIMIT ?17', [$startDate, $endDate, 'completed', 100, 50]);
One thing I always do with raw queries: wrap them in a dedicated class.
1class ProductSalesReport 2{ 3 public function generate(Carbon $startDate, Carbon $endDate, int $minUnits = 100): Collection 4 { 5 $results = DB::select(' 6 SELECT 7 p.name as product_name, 8 -- ... rest of query 9 ', [$startDate, $endDate, 'completed', $minUnits, 50]);10 11 return collect($results)->map(fn($row) => new ProductSalesRow($row));12 }13}
Now you have a testable, reusable component. The raw SQL is contained, not scattered throughout your controllers.
Laravel 9 introduced a new way to define accessors and mutators using the Attribute class. It's cleaner than the old getFullNameAttribute convention, and it's worth adopting.
1use Illuminate\Database\Eloquent\Casts\Attribute; 2 3class User extends Model 4{ 5 protected function fullName(): Attribute 6 { 7 return Attribute::make( 8 get: fn () => "{$this->first_name} {$this->last_name}", 9 set: fn (string $value) => [10 'first_name' => explode(' ', $value)[0] ?? '',11 'last_name' => explode(' ', $value)[1] ?? '',12 ]13 );14 }15}
The setter returns an array, allowing it to set multiple underlying attributes from a single virtual attribute.
But here's a pattern that's useful for computed attributes that are expensive to calculate: caching.
1protected function statistics(): Attribute 2{ 3 return Attribute::make( 4 get: fn () => $this->calculateStatistics(), 5 )->shouldCache(); 6} 7 8private function calculateStatistics(): array 9{10 // Expensive calculation involving multiple queries11 return [12 'total_orders' => $this->orders()->count(),13 'lifetime_value' => $this->orders()->sum('total'),14 'average_order' => $this->orders()->avg('total'),15 ];16}
The shouldCache() method ensures the calculation only runs once per request, even if you access $user->statistics multiple times.
Now here's a subtle bug that catches people. Accessors don't automatically serialize. If you return your model as JSON, your accessor won't be included unless you explicitly append it.
1class User extends Model 2{ 3 protected $appends = ['full_name']; 4 5 protected function fullName(): Attribute 6 { 7 return Attribute::make( 8 get: fn () => "{$this->first_name} {$this->last_name}" 9 );10 }11}
But be careful with $appends. Every attribute listed there gets calculated for every serialization. If full_name triggers a database query, you've got an N+1 problem hiding in your API responses.
A safer approach is conditional appending:
1// In your controller2return UserResource::collection(3 User::all()->each->append('full_name')4);5 6// Or more selectively7return $user->append(['full_name', 'statistics']);
This way, you control exactly when expensive attributes are calculated, rather than having them fire automatically on every API response.
When you have a search page with ten different filter options, scopes start to feel inadequate. You end up with controller methods full of conditional query building. There's a cleaner pattern: dedicated query objects.
1class UserSearch 2{ 3 private Builder $query; 4 5 public function __construct() 6 { 7 $this->query = User::query(); 8 } 9 10 public function name(?string $name): self11 {12 if ($name) {13 $this->query->where(function ($q) use ($name) {14 $q->where('first_name', 'like', "%{$name}%")15 ->orWhere('last_name', 'like', "%{$name}%");16 });17 }18 19 return $this;20 }21 22 public function email(?string $email): self23 {24 if ($email) {25 $this->query->where('email', 'like', "%{$email}%");26 }27 28 return $this;29 }30 31 public function status(?string $status): self32 {33 if ($status && in_array($status, ['active', 'inactive', 'suspended'])) {34 $this->query->where('status', $status);35 }36 37 return $this;38 }39 40 public function createdBetween(?Carbon $from, ?Carbon $to): self41 {42 if ($from) {43 $this->query->where('created_at', '>=', $from);44 }45 46 if ($to) {47 $this->query->where('created_at', '<=', $to);48 }49 50 return $this;51 }52 53 public function hasRole(?string $role): self54 {55 if ($role) {56 $this->query->whereHas('roles', fn($q) => $q->where('name', $role));57 }58 59 return $this;60 }61 62 public function orderByRecent(): self63 {64 $this->query->orderBy('created_at', 'desc');65 return $this;66 }67 68 public function get(): Collection69 {70 return $this->query->get();71 }72 73 public function paginate(int $perPage = 15): LengthAwarePaginator74 {75 return $this->query->paginate($perPage);76 }77}
Now your controller is clean:
1public function index(Request $request) 2{ 3 $users = (new UserSearch()) 4 ->name($request->input('name')) 5 ->email($request->input('email')) 6 ->status($request->input('status')) 7 ->createdBetween( 8 $request->date('from'), 9 $request->date('to')10 )11 ->hasRole($request->input('role'))12 ->orderByRecent()13 ->paginate();14 15 return view('users.index', compact('users'));16}
Each filter method handles its own null checking and validation. The controller just passes through the request parameters. Testing becomes trivial since you can test the UserSearch class in isolation.
You can take this further with a from-request factory:
1class UserSearch 2{ 3 public static function fromRequest(Request $request): self 4 { 5 return (new self()) 6 ->name($request->input('name')) 7 ->email($request->input('email')) 8 ->status($request->input('status')) 9 ->createdBetween(10 $request->date('from'),11 $request->date('to')12 )13 ->hasRole($request->input('role'));14 }15}16 17// Controller becomes18public function index(Request $request)19{20 return view('users.index', [21 'users' => UserSearch::fromRequest($request)22 ->orderByRecent()23 ->paginate()24 ]);25}
Global scopes are powerful but dangerous. They apply to every query on a model, which sounds great until you spend an hour debugging why a query isn't returning the records you know exist.
The classic example is soft deletes, which is itself a global scope. But you might want to add your own. Multi-tenant applications often use global scopes to automatically filter by tenant.
1class TenantScope implements Scope2{3 public function apply(Builder $builder, Model $model): void4 {5 if (app()->has('current_tenant')) {6 $builder->where('tenant_id', app('current_tenant')->id);7 }8 }9}
Apply it to your models:
1class Post extends Model2{3 protected static function booted(): void4 {5 static::addGlobalScope(new TenantScope);6 }7}
Now every query on Post automatically filters by the current tenant. But here's the thing: you need an escape hatch for admin operations that cross tenant boundaries.
1// Normal queries are scoped2Post::all(); // Only current tenant's posts3 4// Admin needs to see everything5Post::withoutGlobalScope(TenantScope::class)->get();6 7// Or remove all global scopes8Post::withoutGlobalScopes()->get();
The key is making sure your team knows these scopes exist. Document them. Put them in your model's docblock. Nothing's more frustrating than hunting for a bug that turns out to be a global scope you forgot about.
Here's a pattern for global scopes that should sometimes be disabled based on context:
1class TenantScope implements Scope 2{ 3 public function apply(Builder $builder, Model $model): void 4 { 5 if ($this->shouldApply()) { 6 $builder->where('tenant_id', app('current_tenant')->id); 7 } 8 } 9 10 private function shouldApply(): bool11 {12 // Don't apply in console commands13 if (app()->runningInConsole() && !app()->runningUnitTests()) {14 return false;15 }16 17 // Don't apply if no tenant is set18 if (!app()->has('current_tenant')) {19 return false;20 }21 22 // Don't apply for super admins23 if (auth()->check() && auth()->user()->isSuperAdmin()) {24 return false;25 }26 27 return true;28 }29}
Eloquent is a deep tool. You can use it for years and still discover new patterns and techniques. The features I've covered here represent what I consider the most impactful for building production applications, things that improve code quality, performance, and maintainability.
The key insight running through all of this is that Eloquent is not just an ORM. It's a toolkit for expressing complex data operations in a way that's both powerful and readable. Query scopes compose. Casts transform. Relationships eager load intelligently. And when Eloquent isn't the right tool, you have the escape hatch of raw queries.
Start with simple patterns and add complexity only when you need it. Not every project needs query objects or polymorphic morph maps. But when you hit that wall where the simple approach isn't working, having these patterns in your toolbox makes all the difference.
Build something great.
If you enjoyed this article, please consider supporting our work for as low as $5 / month.
Sponsor
Written by
Writing and maintaining @LaravelMagazine. Host of "The Laravel Magazine Podcast". Pronouns: vi/vim.
Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.