Advanced Eloquent techniques are what separate a Laravel application that hums from one that quietly issues four hundred queries per page. Eloquent\'s API is famously approachable, and that is exactly the trap: the easy path works at ten rows and collapses at ten thousand. This post covers the deeper toolkit โ systematic N+1 hunting, query-level aggregation, scopes that keep business language in one place, and casts that let your models speak in rich types โ with an eye on what each technique does to the SQL underneath.
Hunt N+1 queries systematically, not anecdotally
Everyone knows with() fixes N+1 queries; the real problem is finding them before users do. Make the ORM tell you. In AppServiceProvider::boot():

Model::shouldBeStrict(! app()->isProduction());
Strict mode turns three silent degradations into loud exceptions in development and tests: lazy loading a relationship (the N+1 seed), accessing an attribute that was not retrieved (the trap behind select() optimizations), and silently discarding unfillable attributes. In production you log instead of throwing, using Model::handleLazyLoadingViolationUsing() to report offenders without breaking users. Beyond strict mode, remember the subtler N+1 sources: relationship access inside API resources without whenLoaded(), Blade partials that touch $model->relation inside loops, and latest()->first() patterns inside accessors.
Eager loading has more precision than most codebases use. Constrain the eager load rather than loading everything and filtering in PHP:
$authors = Author::with(['books' => fn ($q) => $q->where('published', true)
->select('id', 'author_id', 'title')])
->withCount('books')
->withAvg('reviews', 'rating')
->get();
withCount(), withSum(), withAvg(), and withExists() compile to subqueries in the same statement โ one query where a naive loop would issue hundreds, and no hydration of models you only wanted to count.
Subquery selects: the most underused technique
When you need one value from a related table โ the latest login date, the current status from a history table โ loading the whole relationship is waste. A subquery select fetches it in the parent query:
$users = User::query()
->addSelect(['last_login_at' => Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->limit(1),
])
->orderByDesc('last_login_at')
->get();
One query, sortable at the database level, and pairs beautifully with ->withCasts(['last_login_at' => 'datetime']) so the ad-hoc column behaves like a native date attribute. The same idea powers ordering by relationship values with orderBy() accepting a subquery โ no join gymnastics required. For existence checks, prefer whereHas() โ or its faster cousin whereRelation('reviews', 'rating', '>=', 4) for single-condition cases โ over loading relations and filtering collections in PHP.
Scopes: put the business language in the model
A query scope names a query fragment after the business concept it implements, and that naming is worth more than the deduplication:

class Order extends Model
{
#[Scope]
protected function overdue(Builder $query): void
{
$query->whereNull('paid_at')
->where('due_at', '<', now());
}
#[Scope]
protected function forRegion(Builder $query, Region $region): void
{
$query->whereBelongsTo($region);
}
}
$atRisk = Order::overdue()->forRegion($region)->count();
Modern Laravel lets you declare scopes with the #[Scope] attribute rather than the venerable scopeOverdue() naming convention โ both work, the attribute reads better. The moment the definition of "overdue" changes, it changes in one place, and every report, command, and controller inherits the fix. Global scopes apply automatically to every query on the model โ the mechanism behind soft deletes โ and suit invariants like multi-tenancy, but use them sparingly: an invisible where on every query is powerful and confusing in equal measure, and debugging sessions that end in withoutGlobalScope() are a rite of passage.
Casts: models that speak in types
Built-in casts (datetime, boolean, encrypted, array, enums via OrderStatus::class) cover the basics; custom casts are where Eloquent meets domain modeling. A value object cast turns a scattered pair of columns into a typed concept:
class MoneyCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): Money
{
return new Money($attributes['amount_cents'], $attributes['currency']);
}
public function set(Model $model, string $key, mixed $value, array $attributes): array
{
return [
'amount_cents' => $value->cents,
'currency' => $value->currency,
];
}
}
Now $order->price->format() works everywhere, arithmetic lives on the Money object instead of leaking into controllers, and the cents-versus-floats bug class disappears. Declare casts in the model\'s casts() method (the modern method form, which can call parameterized helpers like AsCollection::of(Receipt::class)). For single-column transformations that do not warrant a class, modern attribute accessors keep both directions together:
protected function name(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
set: fn (string $value) => strtolower($value),
)->shouldCache();
}
Working at scale: chunking, lazy collections, and pruning
Any operation touching more rows than fits comfortably in memory needs a streaming strategy. chunkById(1000, ...) processes batches with stable keyset pagination (plain chunk() plus update on the same column skips rows โ a classic bug); lazyById() wraps the same mechanics in a LazyCollection so you can write ->filter()->each() pipelines over millions of rows at constant memory; cursor() streams a single query one hydrated model at a time. For bulk writes, skip Eloquent events entirely: upsert() performs insert-or-update in one statement, and mass update() on a query beats iterating models โ just remember neither fires model events or touches timestamps automatically. And for tables that only grow โ logs, webhooks, expired tokens โ the Prunable trait plus a scheduled model:prune keeps them honest without hand-written cleanup commands.

Closing thoughts
The through-line in all of these techniques is the same question: what SQL does this produce, and how many times does it run? Strict mode makes the ORM answer loudly, subquery selects and aggregate loading collapse round-trips, scopes keep query logic where the domain lives, and casts push type richness into the model layer without sacrificing the database\'s shape. Eloquent will happily let you ignore all of this โ right up until the table has real data in it. Learn the deeper API and you get to keep the expressiveness that made you choose Laravel, at production scale.
Related Service
๐ป Web Development
Custom websites and web applications built with PHP, Laravel, WordPress, and React by a Chandigarh & Mohali based web developer โ fast, secure, scalable, and trusted by clients across India and worldwide.
Explore Web Development →
Reviews & Comments
Reviews are moderated and appear after approval.
No reviews yet โ be the first to share your thoughts.
Leave a Review