3.8 KiB
Advanced Query Patterns
Use addSelect() Subqueries for Single Values from Has-Many
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via addSelect(). This pulls the value directly in the main SQL query — zero extra queries.
public function scopeWithLastLoginAt($query): void
{
$query->addSelect([
'last_login_at' => Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])->withCasts(['last_login_at' => 'datetime']);
}
Create Dynamic Relationships via Subquery FK
Extend the addSelect() pattern to fetch a foreign key via subquery, then define a belongsTo relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
public function lastLogin(): BelongsTo
{
return $this->belongsTo(Login::class);
}
public function scopeWithLastLogin($query): void
{
$query->addSelect([
'last_login_id' => Login::select('id')
->whereColumn('user_id', 'users.id')
->latest()
->take(1),
])->with('lastLogin');
}
Use Conditional Aggregates Instead of Multiple Count Queries
Replace N separate count() queries with a single query using CASE WHEN inside selectRaw(). Use toBase() to skip model hydration when you only need scalar values.
$statuses = Feature::toBase()
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
->first();
Use setRelation() to Prevent Circular N+1
When a parent model is eager-loaded with its children, and the view also needs $child->parent, use setRelation() to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
$feature->load('comments.user');
$feature->comments->each->setRelation('feature', $feature);
Prefer whereIn + Subquery Over whereHas
whereHas() emits a correlated EXISTS subquery that re-executes per row. Using whereIn() with a select('id') subquery lets the database use an index lookup instead, without loading data into PHP memory.
Incorrect (correlated EXISTS re-executes per row):
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
Correct (index-friendly subquery, no PHP memory overhead):
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
Sometimes Two Simple Queries Beat One Complex Query
Running a small, targeted secondary query and passing its results via whereIn is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
Use Compound Indexes Matching orderBy Column Order
When ordering by multiple columns, create a single compound index in the same column order as the ORDER BY clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
// Migration
$table->index(['last_name', 'first_name']);
// Query — column order must match the index
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
Use Correlated Subqueries for Has-Many Ordering
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside orderBy() instead, paired with an addSelect scope for eager loading.
public function scopeOrderByLastLogin($query): void
{
$query->orderByDesc(Login::select('created_at')
->whereColumn('user_id', 'users.id')
->latest()
->take(1)
);
}