laravelmastery
SnippetsArticlesSeriesNews

Eloquent, properly · Lesson 3 of 3

Eloquent, properly · Lesson 3

Local and global scopes

Query it safely and fastEloquentQueries

The through relationship reached every line the team has billed. A local scope is a named constraint you opted into. A global scope is a constraint every query gets, whether the caller remembered it or not. In a multi-tenant app the dangerous one is the global scope you forget you added. The safe default is a method the controller has to call.

Video

01 Name the constraints you reach for

issued() and forTeam() are local. The call site shows the constraint. The scope method is named scopeIssued, and you call it as issued(). Laravel strips the prefix. The Builder argument is the query so far, and a void return adds the where without replacing that query.

Invoice.phpPHP
class Invoice extends Model
{
    public function scopeIssued(Builder $query): void
    {
        $query->where('status', 'issued');
    }

    public function scopeForTeam(Builder $query, Team $team): void
    {
        $query->whereBelongsTo($team);
    }
}

The controller reads as the question you meant to ask. forTeam() uses whereBelongsTo(), so this scope does not type the column either. A rename of the foreign key stays inside the relationship. paginate() belongs at the call site, not inside the scope. A scope that paginates has decided the page size for every caller, including a command that wanted the whole set.

PHP
$invoices = Invoice::query()
    ->forTeam($team)
    ->issued()
    ->latest('issued_on')
    ->paginate(20);

A global scope that always adds team_id is useful only when every query has a current team, including jobs, commands and tests. If a report must cross teams, you will be calling withoutGlobalScope() and hoping you found every call site. Prefer the local scope until the omission has actually shipped a bug. The omission is obvious: the controller that forgot forTeam() returns every tenant, and a test can see that. A global scope hides the same bug inside a process that had no team to begin with.

02 Group an orWhere so it cannot escape the team

forTeam() adds a where. An orWhere() chained after it is a sibling, not a child. The SQL is team_id = ? AND status = issued OR status = paid, and the paid row of another team matches the OR. The scope did its job. The call site undid it. Put the alternative statuses inside a grouped where, either in the controller or in a scope whose only job is that group.

PHP
public function scopeIssuedOrPaid(Builder $query): void
{
    $query->where(function (Builder $query): void {
        $query->where('status', 'issued')
            ->orWhere('status', 'paid');
    });
}

Call it after forTeam(). The group is one constraint, so the team still applies to both statuses. An orWhere() written loose, next to forTeam(), is the leak. Inside a scope, Laravel groups the wheres that scope added when one of them is an or, so they cannot escape the conditions already on the query.

orWhereBelongsTo() at the call site is the same trap. A global team scope would have kept team_id on that query: Laravel groups the existing or conditions, then adds the global constraint. That is a real argument for the global scope. It does not retire the first section. A job or a report with no current team still inherits the filter, and the grouped local scope stays the call you can read.

A scope is a poor place to eager load "just in case", and a poor place to order every query. Those choices belong on the page that displays them.

Recap

You can name issued() and forTeam() as local scopes, and keep an orWhere from leaking another team's invoices.

  • A local scope is visible at the call site.
  • A global scope applies to jobs and commands that forgot the team.
  • withoutGlobalScope() scattered through the app is the cost of the global version.
  • An orWhere at the call site belongs inside a grouped where, or it escapes forTeam().

Check yourself · 3 questions

Question 1 of 3A nightly report must include every team's invoices. A global team scope is already on the model. What is the likely bug?

forTeam() keeps the list on one team. Next, load the lines that list will actually show.

← PreviousHas many through

On this page

  • Name the constraints you reach for
  • Group an orWhere so it cannot escape the team
  • Recap
  • Check yourself

Eloquent, properly

  • 1 Overview

Shape the data

  • 2 Models that match the domain
  • 3 Migrations you can still read next year
  • 4 Casts, enums and custom casters

Connect it

  • 5 One to many, without the surprises
  • 6 Belongs-to and whereBelongsTo
  • 7 Many to many and pivot data
  • 8 Has many through

Query it safely and fast

  • 9 Local and global scopes
  • 10 Eager loading on purpose
  • 11 Catching N+1 queries
  • 12 Indexes, select and chunking
  • 13 API resources without leaking attributes

Eloquent, properly

Lessons

  1. 01Overview

Shape the data

  1. 02Models that match the domain
  2. 03Migrations you can still read next year
  3. 04Casts, enums and custom casters

Connect it

  1. 05One to many, without the surprises
  2. 06Belongs-to and whereBelongsTo
  3. 07Many to many and pivot data
  4. 08Has many through

Query it safely and fast

  1. 09Local and global scopes
  2. 10Eager loading on purpose
  3. 11Catching N+1 queries
  4. 12Indexes, select and chunking
  5. 13API resources without leaking attributes

Get the Friday email

One snippet and the week’s Laravel news, in your inbox every Friday morning. Unsubscribe whenever you like.

laravelmastery

Practical Laravel learning for working developers, made in the UK.

Learn

  • Snippets
  • Articles
  • Series

Stay current

  • News
  • Friday email
  • RSS feed

Company

  • About
  • Privacy
  • Terms
© 2026 Laravel Mastery. Independent and not affiliated with Laravel Holdings Inc.Designed and built by Half Shell Studios

We remember the videos you watch, where you left off, and which series lessons you mark as read, against this connection’s address. Nothing is stored until you agree.