Eloquent Scopes: The Underrated Power Tool

/ Content

I'll be honest, for the longest time I thought Eloquent scopes were basically just syntactic sugar for adding a where('active', true) to a query. I'd define a scopeActive() on a model, feel good about myself, and move on. It wasn't until I inherited a project with a gnarly reporting dashboard that I realized scopes can do a lot more than that.

The Basics Are Fine, But We Can Do More

Everyone knows the basic scope pattern. You stick a method on your model, prefix it with scope, and Laravel lets you chain it on queries. But what most people don't explore is that scopes can accept parameters, compose with each other, and even join related tables, turning your model into a pretty expressive query API.

Say you've got an Order model and you want to filter by date ranges. Instead of writing where clauses everywhere, you can build a parameterized scope:

class Order extends Model
{
    public function scopeCreatedBetween(Builder $query, Carbon $from, Carbon $to): void
    {
        $query->whereBetween('created_at', [$from, $to]);
    }

    public function scopeWithMinimumTotal(Builder $query, float $amount): void
    {
        $query->where('total', '>=', $amount);
    }

    public function scopeForCustomer(Builder $query, int $customerId): void
    {
        $query->where('customer_id', $customerId);
    }
}

Now your controller or service class reads like a sentence:

$orders = Order::query()
    ->createdBetween($startDate, $endDate)
    ->withMinimumTotal(100.00)
    ->forCustomer($request->integer('customer_id'))
    ->latest()
    ->paginate();

That reads well, it's testable, and when the business logic for "minimum total" changes (maybe it needs to exclude tax, or account for refunds), you fix it in one place.

Composable Scopes: Building a Filter System

This is where scopes really start to pay off. On that reporting dashboard I mentioned, we had about fifteen different filter combinations that users could apply. The initial implementation was a 200-line controller method with nested if statements. It was horrifying.

I refactored the whole thing into composable scopes with a simple filter pattern. The trick is to make scopes conditionally apply themselves:

class Order extends Model
{
    public function scopeFilter(Builder $query, array $filters): void
    {
        $query
            ->when($filters['status'] ?? null, fn (Builder $q, string $status) =>
                $q->where('status', $status)
            )
            ->when($filters['min_total'] ?? null, fn (Builder $q, float $amount) =>
                $q->withMinimumTotal($amount)
            )
            ->when($filters['customer_id'] ?? null, fn (Builder $q, int $id) =>
                $q->forCustomer($id)
            )
            ->when($filters['has_shipping'] ?? false, fn (Builder $q) =>
                $q->whereHas('shipments')
            );
    }
}

Now the controller is just Order::filter($request->validated())->paginate(). Each filter only applies when a value is present. You can compose scopes inside other scopes. It's scopes all the way down.

I've used this pattern on probably a dozen projects at this point, and it scales surprisingly well. When product asks for a new filter, you add one when() clause and a scope method. No touching the controller, no refactoring conditional chains.

Global Scopes: Handle With Care

Global scopes automatically apply to every query on a model. Laravel's SoftDeletes is the most well-known example. It adds where deleted_at is null to every query without you thinking about it.

You can define your own. Maybe you've got a multi-tenant app and every query needs to be scoped to the current tenant:

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('tenant_id', auth()->user()?->tenant_id);
    }
}

Slap that on your model with the ScopedBy attribute or in the booted() method, and every query is automatically tenant-scoped. Sounds great, right?

Here's my unpopular opinion: I almost never use global scopes anymore. They're implicit behavior, and implicit behavior is where bugs hide. I've been burned multiple times by global scopes causing weird issues. A seeder that can't find records because the scope filters them out. A queue job running without an authenticated user so the tenant scope returns null. An admin panel that needs to see all records but the scope is silently hiding half of them.

The problem isn't that global scopes don't work. They work perfectly. The problem is that six months later, a new developer joins the team, writes a query, gets unexpected results, and has absolutely no idea why. The scope is invisible at the call site. You have to know it exists.

My rule of thumb: if every single query on the model truly needs this constraint and removing it would be a data breach or a serious bug, use a global scope. Multi-tenancy where leaking data across tenants is a security incident? Sure, global scope away. Everything else? Just use a local scope and be explicit about it. Order::active()->get() is one extra method call, and the intent is obvious at the call site.

Scopes That Join Tables

One pattern I don't see used enough is scopes to encapsulate joins. Raw joins littered across your codebase are a maintenance nightmare, especially when column names or relationships change. Scopes can wrap that complexity:

public function scopeWithLatestPayment(Builder $query): void
{
    $query->addSelect([
        'latest_payment_amount' => Payment::query()
            ->select('amount')
            ->whereColumn('payments.order_id', 'orders.id')
            ->latest()
            ->limit(1),
    ]);
}

This uses a correlated subquery instead of a join, which I generally prefer since it doesn't change the cardinality of your results like a join can. You call Order::withLatestPayment()->get() and every order has a latest_payment_amount attribute. Reusable, and the SQL complexity is hidden behind a method that reads like English.

A Few Tips

Name your scopes for what they mean, not what they do. scopeReadyForShipping() is better than scopeStatusPaidAndHasAddress(). The implementation might change, but the business concept won't.

Don't put side effects in scopes. A scope should only modify the query builder. I once saw a scope that dispatched an event every time it was called. Don't be that person.

Test your scopes in isolation. Write a test that specifically asserts Order::readyForShipping() returns the right records and excludes the wrong ones. When that scope's logic changes, you'll thank yourself.

Be careful with scope ordering. Some scopes don't commute. Applying scope A then scope B might give different results than B then A, especially if they involve subqueries or joins. If ordering matters, document it.

The Bigger Picture

Scopes aren't just a query convenience. They're an API design tool for your models. When you build a rich set of scopes, other developers (including future you) can write complex queries without knowing the underlying table structure or business rules.

That reporting dashboard I mentioned? After refactoring to composable scopes, the controller went from 200 lines to about 15. And when we added three new filter types the following month, each one was a five-minute change.