Scopes — local scopes, global scopes, withoutGlobalScope
Concept
Local scopes let you package a query constraint into a named method on the model. Any method prefixed with scope and accepting a $query argument becomes callable as a chainable builder method — Laravel strips the prefix and lowercases the first letter automatically. The feature lives in Illuminate\Database\Eloquent\Builder, which proxies unknown method calls to the underlying Illuminate\Database\Query\Builder after checking scopes defined on the model.
Global scopes apply to every query on a model, transparently. The canonical example is SoftDeletes, which registers an anonymous global scope that appends WHERE deleted_at IS NULL to every query. You implement Illuminate\Database\Eloquent\Scope with an apply(Builder $builder, Model $model) method, then register it in the model's booted() hook via static::addGlobalScope(). Anonymous global scopes (closures) avoid needing a separate class but cannot be referenced by name later.
withoutGlobalScope() and withoutGlobalScopes() let you opt out of one or all global scopes for a specific query. This is how withTrashed() is implemented: it calls withoutGlobalScope(SoftDeletingScope::class). When debugging unexpected query behaviour, always check whether a global scope is silently altering the SQL — ->toSql() on the builder is your best friend.
Local scopes can also be parametric. scopeOfType(Builder $query, string $type) becomes User::ofType('admin'). This keeps business vocabulary in the model layer and out of controllers. Combined with tap() or when() on the builder, parametric scopes eliminate most ad-hoc if conditions around query building.
| Scope type | Registration | Applied to | Bypassable |
|---|---|---|---|
| Local | Method prefixed scope | Opt-in call | Always (just don't call it) |
| Global | static::addGlobalScope() in booted() | Every query | withoutGlobalScope(Name::class) |
| Anonymous global | Closure passed to addGlobalScope | Every query | withoutGlobalScope('scopeName') |
Code Example
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
// --- Global scope class ---
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('published_at', '<=', now())
->whereNotNull('published_at');
}
}
// --- Model using global scope + local scopes ---
class Post extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new PublishedScope());
// Anonymous global scope:
static::addGlobalScope('active', fn (Builder $q) => $q->where('active', true));
}
// Local scope — called as Post::popular()
// SQL appended: AND view_count > 1000
public function scopePopular(Builder $query): Builder
{
return $query->where('view_count', '>', 1000);
}
// Parametric local scope — Post::ofCategory('tech')
// SQL appended: AND category = ? [tech]
public function scopeOfCategory(Builder $query, string $category): Builder
{
return $query->where('category', $category);
}
}
// --- Usage ---
// Generates: SELECT * FROM posts
// WHERE published_at <= NOW() AND published_at IS NOT NULL
// AND active = 1
// AND view_count > 1000
// AND category = 'tech'
$posts = Post::popular()->ofCategory('tech')->get();
// Bypass both global scopes:
// SELECT * FROM posts WHERE view_count > 1000 AND category = 'tech'
$all = Post::withoutGlobalScopes()->popular()->ofCategory('tech')->get();
// Bypass only one:
// SELECT * FROM posts WHERE active = 1 AND view_count > 1000
$draft = Post::withoutGlobalScope(PublishedScope::class)->popular()->get();Interview Q&A
Q: What is the difference between a local scope and a global scope in Eloquent, and when would you choose one over the other?
A local scope is opt-in: you call it explicitly in a query chain, giving you full control over when the constraint applies. A global scope is automatic: every query on the model silently includes it without any call site needing to know. Use a global scope for invariants the application must always enforce — multi-tenancy tenant filtering, soft-delete visibility, published/active flags — where forgetting to apply the constraint would be a bug. Use a local scope for reusable query fragments that belong to some call sites but not all, such as "popular posts" or "orders in a date range."
Q: How does Laravel know a method like Post::popular() is a scope and not a missing static method?
Eloquent's __callStatic magic delegates to a new model instance's __call, which in turn calls newQuery() to get an Illuminate\Database\Eloquent\Builder. When you call an unknown method on that builder, Builder::__call checks if the model has a method named scope + ucfirst($method). If found, it calls it, passing the builder as the first argument. The return value is the builder itself, enabling further chaining. This whole path is in Illuminate\Database\Eloquent\Builder::callScope.
Q: How is withTrashed() implemented internally and what does it teach us about global scopes?
SoftDeletes::withTrashed() simply calls static::withoutGlobalScope(SoftDeletingScope::class) on a new query builder and returns it. SoftDeletingScope is a class that implements Scope and appends WHERE deleted_at IS NULL inside apply(). Removing the scope removes that WHERE clause entirely, exposing soft-deleted rows. This pattern teaches that global scopes compose correctly: you can remove a specific one without touching others, and the mechanism is the same whether the scope was registered as a class instance, an anonymous class, or a named closure.