Learn to master Query Scopes in Laravel
Last updated on by Ashley Allen
When building your Laravel applications, you'll likely have to write queries that have constraints which are used in multiple places throughout your application. Maybe you're building a multi-tenant application and you're having to keep adding a where
constraint to your queries to filter by the user's team. Or, maybe you're building a blog and you're having to keep adding a where
constraint to your queries to filter by whether the blog post is published or not.
In Laravel, we can make use of query scopes to help us keep these constraints tidy and reusable in a single place.
In this article, we're going to take a look at local query scopes and global query scopes. We'll learn about the difference between the two, how to create your own, and how to write tests for them.
By the end of the article, you should feel confident using query scopes in your Laravel applications.
What are Query Scopes?
Query scopes allow you to define constraints in your Eloquent queries in a reusable way. They are typically defined as methods on your Laravel models, or as a class that implements the Illuminate\Database\Eloquent\Scope
interface.
Not only are they great for defining reusable logic in a single place, but they can also make your code more readable by hiding complex query constraints behind a simple method call.
Query scopes come in two different types:
- Local query scopes - You have to apply these scopes manually to your queries.
- Global query scopes - These scopes are applied to all queries on the model by default after the query is registered.
If you've ever used Laravel's built-in "soft delete" functionality, you may have already used query scopes without realising it. Laravel makes use of local query scopes to provide you with methods such as withTrashed
and onlyTrashed
on your models. It also uses a global query scope to automatically add a whereNull('deleted_at')
constraint to all queries on the model so that soft-deleted records aren't returned in queries by default.
Let's take a look at how we can create and use local query scopes and global query scopes in our Laravel applications.
Local Query Scopes
Local query scopes are defined as methods on your Eloquent model and allow you to define constraints that can be manually applied to your model queries.
Let's imagine we are building a blogging application that has an admin panel. In the admin panel, we have two pages: one for listing published blog posts and another for listing unpublished blog posts.
We'll imagine the blog posts are accessed using an \App\Models\Article
model and that the database table has a nullable published_at
column that stores the date and time the blog post is to be published. If the published_at
column is in the past, the blog post is considered published. If the published_at
column is in the future or null
, the blog post is considered unpublished.
To get the published blog posts, we could write a query like this:
use App\Models\Article; $publishedPosts = Article::query() ->where('published_at', '<=', now()) ->get();
To get the unpublished blog posts, we could write a query like this:
use App\Models\Article;use Illuminate\Contracts\Database\Eloquent\Builder; $unpublishedPosts = Article::query() ->where(function (Builder $query): void { $query->whereNull('published_at') ->orWhere('published_at', '>', now()); }) ->get();
The queries above aren't particularly complex. However, let's imagine we are using them in multiple places throughout our application. As the number of occurrences grows, it becomes more likely that we'll make a mistake or forget to update the query in one place. For instance, a developer might accidentally use >=
instead of <=
when querying for published blog posts. Or, the logic for determining if a blog post is published might change, and we'll need to update all the queries.
This is where query scopes can be extremely useful. So let's tidy up our queries by creating local query scopes on the \App\Models\Article
model.
Local query scopes are defined by creating a method that starts with the word scope
and ends with the intended name of the scope. For example, a method called scopePublished
will create a published
scope on the model. The method should accept an Illuminate\Contracts\Database\Eloquent\Builder
instance and return an Illuminate\Contracts\Database\Eloquent\Builder
instance.
We'll add both of the scopes to the \App\Models\Article
model:
declare(strict_types=1); namespace App\Models; use Illuminate\Contracts\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Relations\BelongsTo; final class Article extends Model{ public function scopePublished(Builder $query): Builder { return $query->where('published_at', '<=', now()); } public function scopeNotPublished(Builder $query): Builder { return $query->where(function (Builder $query): Builder { return $query->whereNull('published_at') ->orWhere('published_at', '>', now()); }); } // ...}
As we can see in the example above, we've moved our where
constraints from our previous queries into two separate methods: scopePublished
and scopeNotPublished
. We can now use these scopes in our queries like this:
use App\Models\Article; $publishedPosts = Article::query() ->published() ->get(); $unpublishedPosts = Article::query() ->notPublished() ->get();
In my personal opinion, I find these queries much easier to read and understand. It also means that if we need to write any queries in the future with the same constraint, we can reuse these scopes.
Global Query Scopes
Global query scopes perform a similar function to local query scopes. But rather than manually being applied on a query-by-query basis, they're automatically applied to all queries on the model.
As we mentioned earlier, Laravel's built-in "soft delete" functionality makes use of the Illuminate\Database\Eloquent\SoftDeletingScope
global query scope. This scope automatically adds a whereNull('deleted_at')
constraint to all queries on the model. You can check out the source code on GitHub here if you're interested in seeing how it works under the hood.
For example, imagine you're building a multi-tenant blogging application that has an admin panel. You'd only want to allow users to view articles that belonged to their team. So, you might write a query like this:
use App\Models\Article; $articles = Article::query() ->where('team_id', Auth::user()->team_id) ->get();
This query is fine, but it's easy to forget to add the where
constraint. If you were writing another query and forgot to add the constraint, you'd end up with a bug in your application that would allow users to interact with articles that didn't belong to their team. Of course, we don't want that to happen!
To prevent this, we can create a global scope that we can apply automatically to all our App\Model\Article
model queries.
How to Create Global Query Scopes
Let's create a global query scope that filters all queries by the team_id
column.
Please note, that we're keeping the example simple for the purposes of this article. In a real-world application, you'd likely want to use a more robust approach that handles things like the user not being authenticated, or the user belonging to multiple teams. But for now, let's keep it simple so we can focus on the concept of global query scopes.
We'll start by running the following Artisan command in our terminal:
php artisan make:scope TeamScope
This should have created a new app/Models/Scopes/TeamScope.php
file. We'll make some updates to this file and then look at the finished code:
declare(strict_types=1); namespace App\Models\Scopes; use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Scope;use Illuminate\Support\Facades\Auth; final readonly class TeamScope implements Scope{ /** * Apply the scope to a given Eloquent query builder. */ public function apply(Builder $builder, Model $model): void { $builder->where('team_id', Auth::user()->team_id); }}
In the code example above, we can see that we've got a new class that implements the Illuminate\Database\Eloquent\Scope
interface and has a single method called apply
. This is the method where we define the constraints we want to apply to the queries on the model.
Our global scope is now ready to be used. We can add it to any models where we want to scope the queries down to the user's team.
Let's apply it to the \App\Models\Article
model.
Applying Global Query Scopes
There are several ways to apply a global scope to a model. The first way is to use the Illuminate\Database\Eloquent\Attributes\ScopedBy
attribute on the model:
declare(strict_types=1); namespace App\Models; use App\Models\Scopes\TeamScope;use Illuminate\Database\Eloquent\Attributes\ScopedBy;use Illuminate\Database\Eloquent\Model; #[ScopedBy(TeamScope::class)]final class Article extends Model{ // ...}
Another way is to use the addGlobalScope
method in the booted
method of the model:
declare(strict_types=1); namespace App\Models; use App\Models\Scopes\TeamScope;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Model; final class Article extends Model{ use HasFactory; protected static function booted(): void { static::addGlobalScope(new TeamScope()); } // ...}
Both of these approaches will apply the where('team_id', Auth::user()->team_id)
constraint to all queries on the \App\Models\Article
model.
This means you can now write queries without having to worry about filtering by the team_id
column:
use App\Models\Article; $articles = Article::query()->get();
If we assume the user is part of a team with the team_id
of 1
, the following SQL would be generated for the query above:
select * from `articles` where `team_id` = 1
That's pretty cool, right!?
Anonymous Global Query Scopes
Another way to define and apply a global query scope is to use an anonymous global scope.
Let's update our \App\Models\Article
model to use an anonymous global scope:
declare(strict_types=1); namespace App\Models; use Illuminate\Contracts\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Illuminate\Support\Facades\Auth; final class Article extends Model{ protected static function booted(): void { static::addGlobalScope('team_scope', static function (Builder $builder): void { $builder->where('team_id', Auth::user()->team_id); }); } // ...}
In the code example above, we've used the addGlobalScope
method to define an anonymous global scope in the model's booted
method. The addGlobalScope
method takes two arguments:
- The name of the scope - This can be used to reference the scope later if you need to ignore it in a query
- The scope constraints - A closure that defines the constraints to apply to the queries
Just like the other approaches, this will apply the where('team_id', Auth::user()->team_id)
constraint to all queries on the \App\Models\Article
model.
In my experience, anonymous global scopes are less common than defining a global scope in a separate class. But it's good to know they're available to use if you need them.
Ignoring Global Query Scopes
There may be times when you want to write a query that doesn't use a global query scope that's been applied to a model. For example, you might be building a report or analytics query that needs to include all records, regardless of the global query scopes.
If this is the case, you can use one of two methods to ignore global scopes.
The first method is withoutGlobalScopes
. This method allows you to ignore all global scopes on the model if no arguments are passed to it:
use App\Models\Article; $articles = Article::query()->withoutGlobalScopes()->get();
Or, if you'd prefer to only ignore a given set of global scopes, you can the scope names to the withoutGlobalScopes
method:
use App\Models\Article;use App\Models\Scopes\TeamScope; $articles = Article::query() ->withoutGlobalScopes([ TeamScope::class, 'another_scope', ])->get();
In the example above, we're ignoring the App\Models\Scopes\TeamScope
and another imaginary anonymous global scope called another_scope
.
Alternatively, if you'd prefer to ignore a single global scope, you can use the withoutGlobalScope
method:
use App\Models\Article;use App\Models\Scopes\TeamScope; $articles = Article::query()->withoutGlobalScope(TeamScope::class)->get();
Global Query Scope Gotchas
It's important to remember that global query scopes are only applied to queries made through your models. If you're writing a database query using the Illuminate\Support\Facades\DB
facade, the global query scopes won't be applied.
For example, let's say you write this query that you'd expect would only grab the articles belonging to the logged-in user's team:
use Illuminate\Support\Facades\DB; $articles = DB::table('articles')->get();
In the query above, the App\Models\Scopes\TeamScope
global query scope won't be applied even if the scope is defined on the App\Models\Article
model. So, you'll need to make sure you're manually applying the constraint in your database queries.
Testing Local Query Scopes
Now that we've learned about how to create and use query scopes, we'll take a look at how we can write tests for them.
There are several ways to test query scopes, and the method you choose may depend on your personal preference or the contents of the scope you're writing. For instance, you may want to write more unit-style tests for the scopes. Or, you may want to write more integration-style tests that test the scope in the context of being used in something like a controller.
Personally, I like to use a mixture of the two so that I can have confidence the scopes are adding the correct constraints, and that the scopes are actually being used in the queries.
Let's take our example published
and notPublished
scopes from earlier and write some tests for them. We'll want to write two different tests (one for each scope):
- A test that checks the
published
scope only returns articles that have been published. - A test that checks the
notPublished
scope only returns articles that haven't been published.
Let's take a look at the tests and then discuss what's being done:
declare(strict_types=1); namespace Tests\Feature\Models\Article; use App\Models\Article;use Illuminate\Foundation\Testing\LazilyRefreshDatabase;use PHPUnit\Framework\Attributes\Test;use Tests\TestCase; final class ScopesTest extends TestCase{ use LazilyRefreshDatabase; protected function setUp(): void { parent::setUp(); // Create two published articles. $this->publishedArticles = Article::factory() ->count(2) ->create([ 'published_at' => now()->subDay(), ]); // Create an unpublished article that hasn't // been scheduled to publish. $this->unscheduledArticle = Article::factory() ->create([ 'published_at' => null, ]); // Create an unpublished article that has been // scheduled to publish. $this->scheduledArticle = Article::factory() ->create([ 'published_at' => now()->addDay(), ]); } #[Test] public function only_published_articles_are_returned(): void { $articles = Article::query()->published()->get(); $this->assertCount(2, $articles); $this->assertTrue($articles->contains($this->publishedArticles->first())); $this->assertTrue($articles->contains($this->publishedArticles->last())); } #[Test] public function only_not_published_articles_are_returned(): void { $articles = Article::query()->notPublished()->get(); $this->assertCount(2, $articles); $this->assertTrue($articles->contains($this->unscheduledArticle)); $this->assertTrue($articles->contains($this->scheduledArticle)); }}
We can see in the test file above, we're first creating some data in the setUp
method. We're creating two published articles, one unscheduled article, and one scheduled article.
There is then a test (only_published_articles_are_returned
) that checks the published
scope only returns the published articles. And there is another test (only_not_published_articles_are_returned
) that checks the notPublished
scope only returns the articles that haven't been published.
By doing this, we can now have confidence that our query scopes are applying the constraints as expected.
Testing Scopes in Controllers
As we mentioned, another way of testing query scopes is to test them in the context of being used in a controller. Whereas an isolated test for the scope can help to assert that a scope is adding the correct constraints to a query, it doesn't actually test that the scope is being used as intended in the application. For instance, you may have forgotten to add the published
scope to a query in a controller method.
These types of mistakes can be caught by writing tests that assert the correct data is returned when the scope is used in a controller method.
Let's take our example of having a multi-tenant blogging application and write a test for a controller method that lists articles. We'll assume we have a very simple controller method like so:
declare(strict_types=1); namespace App\Http\Controllers; use App\Models\Article;use Illuminate\Http\Request; final class ArticleController extends Controller{ public function index() { return view('articles.index', [ 'articles' => Article::all(), ]); }}
We'll assume that the App\Models\Article
model has our App\Models\Scopes\TeamScope
applied to it.
We'll want to assert that only the articles belonging to the user's team are returned. The test case may look something like this:
declare(strict_types=1); namespace Tests\Feature\Controllers\ArticleController; use App\Models\Article;use App\Models\Team;use App\Models\User;use Illuminate\Database\Eloquent\Collection;use Illuminate\Foundation\Testing\LazilyRefreshDatabase;use PHPUnit\Framework\Attributes\Test;use Tests\TestCase; final class IndexTest extends TestCase{ use LazilyRefreshDatabase; #[Test] public function only_articles_belonging_to_the_team_are_returned(): void { // Create two new teams. $teamOne = Team::factory()->create(); $teamTwo = Team::factory()->create(); // Create a user that belongs to team one. $user = User::factory()->for($teamOne)->create(); // Create 3 articles for team one. $articlesForTeamOne = Article::factory() ->for($teamOne) ->count(3) ->create(); // Create 2 articles for team two. Article::factory() ->for($teamTwo) ->count(2) ->create(); // Act as the user and make a request to the controller method. We'll // assert that only the articles belonging to team one are returned. $this->actingAs($user) ->get('/articles') ->assertOk() ->assertViewIs('articles.index') ->assertViewHas( key: 'articles', value: fn (Collection $articles): bool => $articles->pluck('id')->all() === $articlesForTeamOne->pluck('id')->all() ); }}
In the test above, we're creating two teams. We're then creating a user that belongs to team one. We're creating 3 articles for team one and 2 articles for team two. We're then acting as the user and making a request to the controller method that lists the articles. The controller method should only be returning the 3 articles that belong to team one, so we're asserting that only those articles are returned by comparing the IDs of the articles.
This means we can then have confidence that the global query scope is being used as intended in the controller method.
Conclusion
In this article, we learned about local query scopes and global query scopes. We learned about the difference between the two, how to create your own and use them, and how to write tests for them.
Hopefully, you should now feel confident using query scopes in your Laravel applications.