Contents
Every reporting project I've inherited has the same smell: the same WHERE clause duplicated across a dozen controllers, slightly different each time. One has a bug. Three more have the same bug in a different way. Nobody knows which version is authoritative.
Eloquent scopes are the fix. They're named, reusable query conditions that live on the model — one definition, used everywhere. When you need to add a dashboard filter or build a new report, you're assembling building blocks instead of rewriting logic.
What a Scope Looks Like
A local scope is a method on your model prefixed with scope:
class Order extends Model
{
public function scopeCompleted(Builder $query): Builder
{
return $query->where('status', 'completed');
}
public function scopeThisMonth(Builder $query): Builder
{
return $query->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year);
}
public function scopeAbove(Builder $query, int $amount): Builder
{
return $query->where('total', '>', $amount);
}
}
Plain English: each method extends the query builder with a named condition. The scope prefix gets stripped when you call it.
Now you use them like this:
$orders = Order::completed()->thisMonth()->above(500)->get();
That reads like English. More importantly, completed() means the same thing here as it does in every other part of your codebase. There's one source of truth.
Building Reports Without Rewriting Queries
Here's where scopes pay off at the business level. Say you're building a month-end revenue report. You need completed orders, this month, grouped by product category. You already have the pieces:
$report = Order::completed()
->thisMonth()
->with('lineItems.product')
->selectRaw('product_category, SUM(total) as revenue, COUNT(*) as count')
->join('line_items', 'orders.id', '=', 'line_items.order_id')
->groupBy('product_category')
->get();
The filter logic is already defined. You're composing, not rewriting. When the product manager asks for the same report but for Q3 instead of this month, you add a scopeInQuarter method and swap one chain call.
Global Scopes for Always-On Logic
Sometimes a condition applies to every single query on a model. Multi-tenant apps are the classic example — every query should be filtered by the current tenant. Global scopes handle this automatically:
class Order extends Model
{
protected static function booted(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
$query->where('tenant_id', auth()->user()?->tenant_id);
});
}
}
Now every Order:: query is automatically tenant-scoped. You can't forget to add it. You can't accidentally omit it in a new controller. It's enforced at the model level.
When you legitimately need to bypass it (admin reports, migrations), you call Order::withoutGlobalScope('tenant'). Explicit, intentional, auditable.
The Real Payoff: Speed and Consistency
The business case for scopes is simple. The first time you define completed(), it takes two minutes. Every time you use it after that, it takes two seconds. A dashboard with six filters is an afternoon, not a week.
More importantly, your reports are consistent. The "completed orders" number on the dashboard matches the export matches the API endpoint because they're all using the same scope. When a bug is found, you fix it in one place. Every report that uses that scope is fixed immediately.
I've built complex reporting systems in Laravel where the whole query layer is essentially scope composition. New reports get added in hours because the vocabulary already exists. That's the compounding return on writing good model code upfront.
If your app has reports or dashboards that feel harder to build than they should be, let's talk about the architecture. Usually there's a clean path forward.