$ lexprog.com

// notes from an old coder -- php, databases, and the occasional rant

[January 19, 2025] Eloquent ORM

Eloquent Query Scopes

Eloquent Query Scopes: Tips & Tricks

────────────────────────────────────────────────────────

Eloquent Query Scopes: Tips & Tricks

Tip: Chain Scopes for Readability

Post::published()->popular()->recent()->get();

Each scope returns the query builder, so they chain naturally.

Gotcha: Global Scopes Apply Automatically

static::addGlobalScope('active', function ($builder) {
    $builder->where('active', true);
});

Every query on this model gets the scope. Use withoutGlobalScope() to bypass.

Tip: Dynamic Scopes Accept Parameters

public function scopeOfType(Builder $query, string $type): Builder
{
    return $query->where('type', $type);
}

// Usage:
Post::ofType('article')->get();

Tip: Use Scopes in Relationships

public function publishedPosts(): HasMany
{
    return $this->hasMany(Post::class)->published();
}

Gotcha: Scopes Don't Work with find()

Post::published()->find(1); // Works
Post::find(1)->published(); // Doesn't work — find() returns a model

Tip: Scope Reusability Across Models

trait HasPublishedScope
{
    public function scopePublished(Builder $query): Builder
    {
        return $query->whereNotNull('published_at');
    }
}

Tip: Scope for Ordering

public function scopeLatest(Builder $query): Builder
{
    return $query->orderByDesc('created_at');
}

Gotcha: Global Scope Removal

Post::withoutGlobalScope('active')->get();
Post::withoutGlobalScopes()->get(); // Remove all

Tip: Use cursor() for Memory-Neutral Iteration

When exporting 100K rows, get() loads everything into memory. cursor() uses yield and keeps memory flat regardless of row count. Perfect for artisan commands.

Tip: whereHas() vs load() — Two Different Things

whereHas() filters the parent query by relationship existence. load() eager-loads relationships AFTER the query. Mixing them up is a common source of logic bugs.

Gotcha: withCount() Adds a Subquery

withCount('comments') runs a correlated subquery on every row. On large tables, this can be slower than a separate query. Profile before relying on it.

Senior Insight

Query scopes are Laravel's answer to reusable query logic, and they're excellent when used correctly. The mistake I see most often: scopes that accept optional parameters with complex default logic, making them unpredictable. I follow the principle of least surprise — each scope should do exactly one filtering operation, and the name should describe exactly what it does. active() returns active records. recent() orders by created_at. Never combine filtering and ordering in one scope.

Source: Laravel Docs (https://laravel.com/docs/eloquent), Laravel News (https://laravel-news.com/), Freek.dev (https://freek.dev/tags/eloquent)

────────────────────────────────────────────────────────
<-- back to posts