Learn to master Query Scopes in Laravel

Last updated on by

Learn to master Query Scopes in Laravel image

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.

Ashley Allen photo

I am a freelance Laravel web developer who loves contributing to open-source projects, building exciting systems, and helping others learn about web development.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

Laravel Forge logo

Laravel Forge

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Forge
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell
No Compromises logo

No Compromises

Joel and Aaron, the two seasoned devs from the No Compromises podcast, are now available to hire for your Laravel project. ⬧ Flat rate of $7500/mo. ⬧ No lengthy sales process. ⬧ No contracts. ⬧ 100% money back guarantee.

No Compromises
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Bacancy logo

Bacancy

Supercharge your project with a seasoned Laravel developer with 4-6 years of experience for just $2500/month. Get 160 hours of dedicated expertise & a risk-free 15-day trial. Schedule a call now!

Bacancy
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel E-Commerce

E-Commerce for Laravel. An open-source package that brings the power of modern headless e-commerce functionality to Laravel.

Lunar: Laravel E-Commerce
LaraJobs logo

LaraJobs

The official Laravel job board

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
Rector logo

Rector

Your partner for seamless Laravel upgrades, cutting costs, and accelerating innovation for successful companies

Rector
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB

The latest

View all →
Pinkary is now fully open source image

Pinkary is now fully open source

Read article
Andrew Schmelyun: Publishing Video Courses, Virtual and Physical Worlds, LLM's image

Andrew Schmelyun: Publishing Video Courses, Virtual and Physical Worlds, LLM's

Read article
The Laracon US 2024 Keynote by Taylor Otwell is Now Available image

The Laracon US 2024 Keynote by Taylor Otwell is Now Available

Read article
Everything We Know about Pest 3 image

Everything We Know about Pest 3

Read article
Highlights from Taylor Otwell's Laracon US Keynote 2024 image

Highlights from Taylor Otwell's Laracon US Keynote 2024

Read article
Laracon US 2024 Live from Dallas image

Laracon US 2024 Live from Dallas

Read article