Files
2026-04-09 16:06:44 -06:00

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)
    );
}