The first time you write Order::active() instead of Order::where('status', 'active')->where('archived', false), it feels like a small win. The second time, two months later, when a junior asks why the admin dashboard is missing half the orders, you remember that active() is one of three scopes silently filtering the query — and one of them was added last quarter to fix an unrelated bug. That is the scope deal in a nutshell. They make queries readable. They also become the cupboard where business rules go to live an unobserved life.
Eloquent scopes are a useful tool. The trick is knowing when they are paying rent and when they are just hiding logic the next person will not find until production lights up.
Local Scopes Are Just Method Composition
The simplest scope is a method on the model that takes a query builder and returns it modified:
class Order extends Model
{
public function scopeActive($query)
{
return $query->where('status', 'active')->where('archived', false);
}
public function scopeForCustomer($query, int $customerId)
{
return $query->where('customer_id', $customerId);
}
}
Now Order::active()->forCustomer($id)->get() reads like English. The scope prefix is the only convention; Laravel strips it when calling the method on the query builder. Laravel 12.4 also added an attribute-based syntax for the same shape:
use Illuminate\Database\Eloquent\Attributes\Scope;
class Order extends Model
{
#[Scope]
public function active($query)
{
return $query->where('status', 'active')->where('archived', false);
}
}
Either form is fine. The win is real: a scope name encodes a business definition once. If "active" later means "status is active OR pending payment AND not archived AND not soft-deleted," you change one method and every caller picks up the new definition.
The price is also real. The next person reading Order::active() cannot see the SQL without opening the model. The longer the chain — ->active()->fresh()->billable()->forRegion('eu') — the more places they have to look. Local scopes work best when the names match how the team actually talks about the data, and when the methods are short enough to read in one screen.
Global Scopes Are A Different Animal
Local scopes are opt-in. Global scopes apply to every query on the model unless you opt out. That is a much bigger commitment.
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($tenantId = app('current_tenant_id')) {
$builder->where($model->getTable() . '.tenant_id', $tenantId);
}
}
}
class Order extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
}
}
Order::all() now silently filters to the current tenant. Every controller, every job, every Artisan command — all of them get tenant isolation for free. That is exactly what you want. You absolutely do not want the tenant filter to be a thing developers have to remember on every query.
Global scopes are also the source of the most confusing bugs Laravel will hand you. The query builder shows zero rows in production. The same query in tinker shows the row you expected. The difference is that tinker did not have a tenant resolved, so the global scope never applied — or applied differently. The fix is the explicit escape:
Order::withoutGlobalScope(TenantScope::class)->find($id);
Order::withoutGlobalScopes()->count();
Both calls remove the silent filter. The first removes one named scope; the second removes all of them. Reach for withoutGlobalScopes() carefully — that is also where you accidentally bypass a soft-delete filter and start mutating "deleted" rows.
The shortcut form for cases that do not need a class is fine too:
static::addGlobalScope('not_archived', fn (Builder $b) => $b->where('archived', false));
Same effect, no extra file.
Where Scopes Help — And Where They Hurt
Scopes are at their best when they encode a single, stable business definition that everyone agrees on. "Active subscription," "fulfillable order," "verified user" — concepts the team uses out loud. Centralizing them is a clear win because the alternative is twelve copies of where('status', '!=', 'cancelled') slowly drifting in different directions.
They start hurting in three situations.
The first is when the scope hides a JOIN or a subquery. User::withRecentActivity() looks like a free read; in fact it adds a LEFT JOIN events and an aggregate. The next person reuses it on a list endpoint that already has three other JOINs and the query plan falls apart. Scopes do not advertise their cost. Treat any scope that touches another table as something to label loudly in the docblock — and to benchmark before reaching for it on a list page.
The second is when scopes accumulate. A model with twelve scopes ends up encoding business rules that should probably be a domain object. If Subscription::active() actually means "status is active AND trial_ends_at is in the future AND payment_failed_at is null AND grace_period_ends_at is in the future," that definition is large enough to deserve a value object or a state machine, not a six-line query method.
The third is when global scopes make tests confusing. Most teams discover this when a feature test fails for reasons unrelated to the feature — some unrelated migration created a row that the global scope filters out, or a factory state forgot to set tenant_id. Tests should usually run with the same global scopes as production, but that means every factory and every helper has to be aware of them.
The Modern Alternative: Query Objects
Scopes are not the only way to keep queries readable. The current trend, especially in larger teams, is the query object — an explicit class whose only job is to assemble a query.
The popular package is Spatie's laravel-query-builder, which lets you expose Eloquent queries to URL filters in a controlled way:
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
$orders = QueryBuilder::for(Order::class)
->allowedFilters([
AllowedFilter::exact('status'),
AllowedFilter::scope('forCustomer'), // delegates to scopeForCustomer
AllowedFilter::callback('recent', fn ($q) => $q->where('created_at', '>=', now()->subDays(30))),
])
->allowedSorts(['created_at', 'amount'])
->paginate();
The query object pattern goes further when teams want zero magic. A plain class with a build() method and a constructor full of filters is explicit, testable, and easy to follow:
final class OrderListingQuery
{
public function __construct(
private readonly ?int $customerId = null,
private readonly ?string $status = null,
private readonly bool $onlyRecent = false,
) {}
public function build(): \Illuminate\Database\Eloquent\Builder
{
$q = Order::query();
if ($this->customerId) $q->where('customer_id', $this->customerId);
if ($this->status) $q->where('status', $this->status);
if ($this->onlyRecent) $q->where('created_at', '>=', now()->subDays(30));
return $q;
}
}
Trade-off: more files, less DSL. You give up the elegance of Order::active()->forCustomer($id) and gain a query that a new engineer can read top to bottom without grepping the model.
In practice, both patterns coexist. Local scopes for the small, named, stable ideas. Query objects when the filtering grows enough to justify the structure.
Soft Delete Is The Original Global Scope
Worth pointing out, because it catches teams who do not realize they are already using the pattern: the SoftDeletes trait is implemented as a global scope.
class Article extends Model
{
use \Illuminate\Database\Eloquent\SoftDeletes;
}
Every query on Article silently appends WHERE deleted_at IS NULL. To include trashed rows, you call withTrashed(). To get only the trashed rows, onlyTrashed(). To bypass the filter completely, withoutGlobalScope(SoftDeletingScope::class). The exact same mechanism your custom TenantScope uses.
This is the cleanest, most-tested global scope in Laravel — it is worth modeling new global scopes after the way SoftDeletes exposes its escape hatches. Make withTrashed-style methods on your scope so callers do not have to remember the class name.
How To Decide In Code Review
When a PR reaches for a new scope, three questions tend to settle the call:
- Could this be a plain
wherechain in the controller without anyone losing sleep? If yes, the scope is decoration, not abstraction. - Is the same definition repeated in three or more places already? If yes, a scope earns its keep.
- Is the new behavior global — meaning the team would forget to add it manually? If yes, a global scope is the right tool, but document the escape hatch in the same PR.
Scopes are like any other shared abstraction. They pay rent when they encode something meaningful and stable. They become a tax when they are reached for instinctively, hide cost, or accumulate without anyone holding the line. The model file is not a junk drawer — it is a contract about what the data means. Treat scopes like additions to that contract, not like shortcuts.




