Writing Post::where('published', true)->where('user_id', $id) in five different places is exactly what query scopes exist to prevent. It works on day one. Then the definition of "published" changes — scheduled posts, an embargo date, a hidden flag — and you're grepping the codebase, hoping you found every copy.
What you'll learn
- How to turn repeated
wherechains into named, chainable local scopes - The
#[Scope]attribute versus the classicscopeXxxprefix - Scopes with parameters, and scopes inside relationships, eager loads and
whereHas - When a global scope is the right tool — and when it's a trap
- How to write a global scope that fails closed, and how to remove one deliberately
Prerequisites
- A Laravel 12 or 13 project. The
#[Scope]attribute arrived in Laravel 12; on older versions, use thescopeprefix shown in step 2. - Comfort with Eloquent models, relationships and the query builder, as covered in the Laravel Basics series.
- A
poststable withuser_id,team_idand a nullablepublished_attimestamp.
The core idea
A local scope is a named query fragment that lives on the model. You define it once, give it a name that means something, and call it like any other builder method: Post::published()->latest()->get(). It is opt-in: it only applies when you ask for it.
A global scope is a constraint Eloquent adds to every query on that model, automatically: get(), first(), count(), relationship queries, route model binding, even mass updates and deletes. It is opt-out: it applies unless you explicitly remove it. You already use one — SoftDeletes is a global scope that adds whereNull('deleted_at').
That difference gives you a simple design rule: local scopes are vocabulary, global scopes are invariants. "Published", "by this author" and "popular this week" are vocabulary — you need them in some queries and not in others. "A user never sees another team's data" is an invariant — it has to hold even when a developer forgets. Ask yourself: if someone forgets this constraint, is that a bug or just a different query? A bug points to a global scope. A different query points to a local one.
Step by step
Step 1 — Recognise the duplication
This is what it usually looks like before scopes:
// PostController@index
$posts = Post::where('published', true)->latest()->paginate(10);
// DashboardController
$count = Post::where('published', true)->where('user_id', $user->id)->count();
// SitemapController
$posts = Post::where('published', true)->get(['slug', 'updated_at']);
// ...plus the RSS feed, a console command and an admin resource
Now the business wants scheduled posts: a post is published when published_at is set and lies in the past. Every one of those copies is now wrong, and nothing tells you so.
Step 2 — Your first local scope
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
#[Scope]
protected function published(Builder $query): void
{
$query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
}
Using it:
$posts = Post::published()->latest()->paginate(10);
The scheduled-posts requirement is now one edit in one method. On Laravel 11 and earlier — or in a codebase that hasn't switched yet — the same scope uses the prefix convention:
public function scopePublished(Builder $query): void
{
$query->whereNotNull('published_at')
->where('published_at', '<=', now());
}
You still call it as published(). Both work on current versions; pick one style per codebase and stick to it.
Step 3 — Scopes with parameters
Every argument after $query becomes a parameter of the scope:
use App\Models\User;
#[Scope]
protected function byAuthor(Builder $query, User $author): void
{
$query->where('user_id', $author->getKey());
}
Scopes chain like any other builder method:
$count = Post::published()->byAuthor($user)->count();
Type-hint the parameter. Passing an ID where a User is expected now fails loudly instead of quietly returning the wrong rows.
Step 4 — Scopes travel with the builder
Scopes work on every Eloquent builder for the model, including the ones behind relationships:
// Through a relationship
$user->posts()->published()->latest()->get();
// Constraining an eager load
$authors = User::with(['posts' => fn ($query) => $query->published()])->get();
// Filtering parents by their children
$activeAuthors = User::whereHas('posts', fn ($query) => $query->published())->get();
This is where scopes really pay off: the meaning of "published" travels with the model, not with whoever wrote the controller.
One reassuring detail: when a scope adds orWhere clauses, Eloquent wraps them in their own parentheses. A scope like this one can't accidentally swallow the conditions around it:
#[Scope]
protected function highlighted(Builder $query): void
{
$query->where('featured', true)->orWhere('pinned', true);
}
// where user_id = ? and (featured = 1 or pinned = 1)
Post::byAuthor($user)->highlighted()->get();
Step 5 — A global scope that enforces an invariant
Now the invariant: every post belongs to a team, and a user only ever sees their own team's posts. Generate a scope class:
php artisan make:scope TeamScope
This creates app/Models/Scopes/TeamScope.php:
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Context;
class TeamScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$teamId = Context::get('team_id');
if ($teamId === null) {
// Fail closed: no team context means no rows, never "all rows".
$builder->whereRaw('1 = 0');
return;
}
$builder->where($model->qualifyColumn('team_id'), $teamId);
}
}
Two details matter here. qualifyColumn() produces posts.team_id, so the scope doesn't cause "ambiguous column" errors as soon as someone adds a join. And the null check makes the scope fail closed (more on that under common mistakes).
The team ID comes from Laravel's Context, filled by a small middleware:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Symfony\Component\HttpFoundation\Response;
class SetTeamContext
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
Context::add('team_id', $user->current_team_id);
}
return $next($request);
}
}
Register it in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\App\Http\Middleware\SetTeamContext::class,
]);
})
A nice side effect of Context: its data is carried along into queued jobs dispatched during the request, so the scope keeps working inside those jobs.
Finally, attach the scope to the model:
use App\Models\Scopes\TeamScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([TeamScope::class])]
class Post extends Model
{
// ...
}
From now on, Post::all() runs select * from posts where posts.team_id = ?. So does Post::find($id), and so does Post::where(...)->delete(). Note that a global scope only filters queries — it does not fill team_id when you create a post. Use a creating model event or set it explicitly for that.
Step 6 — Anonymous global scopes
For a small constraint that doesn't deserve its own class, register a closure in the model's booted() method:
protected static function booted(): void
{
static::addGlobalScope('not_archived', function (Builder $builder) {
$builder->whereNull('archived_at');
});
}
Remember the string name — you need it to remove the scope later.
Step 7 — Remove global scopes deliberately
// One class-based scope
Post::withoutGlobalScope(TeamScope::class)->count();
// One closure-based scope, by its name
Post::withoutGlobalScope('not_archived')->get();
// Several, or all of them
Post::withoutGlobalScopes([TeamScope::class, 'not_archived'])->get();
Post::withoutGlobalScopes()->get();
Every withoutGlobalScope call switches an invariant off, so treat it as a line that deserves a second look in code review. Wrapping it in a well-named method makes the intent explicit and easy to search for:
// In the Post model
public static function acrossAllTeams(): Builder
{
return static::withoutGlobalScope(TeamScope::class);
}
// In an admin report
$total = Post::acrossAllTeams()->count();
Common mistakes
1. Making a #[Scope] method public. Post::published() works because the method is protected: PHP can't call it directly, so the static call is routed through Eloquent's magic methods, which hand it a builder. Make the method public and PHP calls it directly — statically — and you get Error: Non-static method App\Models\Post::published() cannot be called statically. Keep attribute-based scopes protected. (The scopePublished style is public by convention; the prefix keeps its name from clashing.)
2. A global scope that reads auth(). $builder->where('team_id', auth()->user()?->current_team_id) works in the browser. Then a scheduled command, a queued job or a Tinker session runs without a logged-in user. Laravel turns where('team_id', null) into whereNull('team_id'), so instead of an error you quietly get every post without a team. That's why the scope in step 5 fails closed and reads from Context rather than from the session.
3. Assuming the global scope covers everything. A global scope applies to Eloquent queries: Post::..., relationships and route model binding. It does not apply to DB::table('posts'), raw SQL, or validation rules such as Rule::exists('posts', 'id') — those go through the plain query builder, so add the team constraint there yourself. The opposite surprise happens too: route model binding does respect the scope, so an admin route like /admin/posts/{post} returns a 404 for another team's post. Make that exception explicit with a custom binding:
// routes/web.php
Route::bind('anyPost', fn (string $value) => Post::acrossAllTeams()->findOrFail($value));
Route::get('/admin/posts/{anyPost}', [AdminPostController::class, 'show']);
Recap
- Local scopes are reusable, chainable vocabulary on the model. They are opt-in.
- Global scopes enforce invariants on every Eloquent query. They are opt-out.
- On Laravel 12+, use
#[Scope]on a protected method; thescopeXxxprefix still works. - In global scopes, qualify your columns and fail closed when context is missing.
withoutGlobalScope()switches off a safety net — keep it rare, named and visible.
Where to go next
Next up: basic routing — where route:list actually becomes useful. The full command reference and the workflow habits behind it are baked into the training curriculum.
And if your codebase has reached the point where the same where lives in a dozen places, or tenant isolation depends on every developer remembering one line, that's exactly the kind of thing a codebase audit surfaces quickly. An audit or advisory retainer is a calm way to find those spots before they find you.