<p><img src="/static/imghwm/default1.png" data-src="https://img.php.cn/upload/article/000/000/000/174119929536701.jpg" class="lazy" alt="Learn to master Query Scopes in Laravel"></p>
<p>When building a Laravel application, you may need to write queries with constraints that are used in multiple places throughout the application. Maybe you are building a multi-tenant application and you have to constantly add <code>where</code> constraints to the query to filter by user's team. Or maybe you are building a blog and you have to constantly add <code>where</code> constraints to the query to filter if the blog post has been published. </p>
<p>In Laravel, we can use query scopes to help us keep these constraints neatly in one place and reuse them. </p>
<p>In this article, we will study the local query scope and the global query scope. We will learn the difference between the two, how to create your own query scope, and how to write tests for them. </p>
<p>After reading this article, you should be able to use query scopes confidently in your Laravel application. </p>
<h1>What is query scope? </h1>
<hr>
<p>Query scope allows you to define constraints in Eloquent queries in a reusable way. They are usually defined as methods on the Laravel model, or as classes that implement <code>IlluminateDatabaseEloquentScope</code> interfaces. </p>
<p> Not only are they ideal for defining reusable logic in one place, but they can also make your code more readable by hiding complex query constraints after simple function calls. </p>
<p>Query ranges are divided into two types: </p>
<ul>
<li>Local Query Ranges - You must manually apply these ranges to your query. </li>
<li>Global Query Scopes - By default, these ranges are applied to all queries on the model, provided that the query is registered. </li>
</ul>
<p>If you have ever used the "soft delete" feature built in Laravel, you may have used the query scope unknowingly. Laravel uses local query scope to provide you with methods like <code>withTrashed</code> and <code>onlyTrashed</code> on the model. It also uses the global query scope to automatically add <code>whereNull('deleted_at')</code> constraints to all queries on the model so that soft deleted records are not returned by default in the query. </p>
<p>Let's see how to create and use local query scopes and global query scopes in a Laravel application. </p>
<h1>Local query scope</h1>
<hr>
<p> Local query scope is defined as a method on the Eloquent model, allowing you to define constraints that can be applied manually to model queries. </p>
<p>Suppose we are building a blog application with an admin panel. In the admin panel, we have two pages: one for listing published blog posts and the other for listing unpublished blog posts. </p>
<p>We assume that the blog post is accessed using the <code>AppModelsArticle</code> model and that the database table has an empty <code>published_at</code> column to store the publishing time of the blog post. If <code>published_at</code> is listed in the past, the blog post is considered to have been published. If <code>published_at</code> is listed in the future or is <code>null</code>, the blog post is deemed to be unpublished. </p>
<p>To obtain published blog posts, we can write the following query: </p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->where('published_at', '<', now())
->get();</code></pre>
<p>To obtain unpublished blog posts, we can write the following query: </p>
<pre class="brush:php;toolbar:false"><code>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();</code></pre>
<p>The above query is not particularly complicated. But, suppose we use them in multiple places throughout the application. As the number of occurrences increases, the possibility of us making mistakes or forgetting to update the query in one place is increasing. For example, developers may accidentally use <code>>=</code> instead of <code><</code> to query published blog posts. Alternatively, the logic to determine if a blog post has been published may change and we need to update all queries. </p>
<p>This is where query scopes are very useful. So let's organize our queries by creating a local query scope on the <code>AppModelsArticle</code> model. </p>
<p> Local query scopes are defined by creating a method that starts with <code>scope</code> and ends with the expected name of the scope. For example, a method named <code>scopePublished</code> will create a <code>published</code> range on the model. This method should accept an <code>IlluminateContractsDatabaseEloquentBuilder</code> instance and return a <code>IlluminateContractsDatabaseEloquentBuilder</code> instance. </p>
<p>We add both ranges to the <code>AppModelsArticle</code> model: </p>
<pre class="brush:php;toolbar:false"><code>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());
});
}
// ...
}</code>
</p>
<p> As we saw in the example above, we moved the <code>where</code> constraint from the previous query into two separate methods: <code>scopePublished</code> and <code>scopeNotPublished</code>. We can now use these ranges in our query like this: </p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->published()
->get();
$unpublishedPosts = Article::query()
->notPublished()
->get();</code></pre>
<p>In my personal opinion, I find these queries easier to read and understand. This also means that if we need to write any query with the same constraints in the future, we can reuse these scopes. </p>
<h1>Global query scope</h1>
<hr>
<p>Global query scope performs functions similar to local query scope. However, it is not applied manually on a query-by-query basis, but automatically applies to all queries on the model. </p>
<p> As we mentioned earlier, Laravel's built-in "soft delete" function uses <code>IlluminateDatabaseEloquentSoftDeletingScope</code> global query scope. This range automatically adds <code>whereNull('deleted_at')</code> constraints to all queries on the model. If you are interested in understanding how it works, you can check out the source code on GitHub here. </p>
<p>For example, suppose you are building a multi-tenant blog application with an admin panel. You may just want to allow users to view articles that belong to their team. So you might write a query like this: </p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->where('published_at', '<', now())
->get();</code></pre>
<p>This query is good, but it's easy to forget to add <code>where</code> constraints. If you are writing another query and forgetting to add constraints, you will end up with an error in your application that will allow the user to interact with articles that are not part of their team. Of course, we don't want this to happen! </p>
<p>To prevent this, we can create a global scope that we can automatically apply to all our <code>AppModelArticle</code> model queries. </p>
<h3>#How to create a global query scope</h3>
<p> Let's create a global query scope that filters all queries by the <code>team_id</code> column. </p>
<p> Please note that for the purposes of this article, we keep the example simple. In a real-life application, you may want to use a more powerful approach to dealing with situations like the user is not authenticated or the user belongs to multiple teams. But for now, let's keep it simple so we can focus on the concept of global query scope. </p>
<p>We will first run the following Artisan command in the terminal: </p>
<pre class="brush:php;toolbar:false"><code>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();</code></pre>
<p> This should have created a new <code>app/Models/Scopes/TeamScope.php</code> file. We will make some updates to this file and then look at the finished code: </p>
<pre class="brush:php;toolbar:false"><code>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());
});
}
// ...
}</code></pre>
<p> In the above code example we can see that we have a new class that implements the <code>IlluminateDatabaseEloquentScope</code> interface and has a single method called <code>apply</code>. This is how we define the constraints to apply to model queries. </p>
<p>Our global scope is now available. We can add this to any model we want to narrow the query to the user team. </p>
<p>Let's apply it to the <code>AppModelsArticle</code> model. </p>
<h3>#Apply global query scope</h3>
<p>There are several ways to apply the global scope to the model. The first method is to use the <code>IlluminateDatabaseEloquentAttributesScopedBy</code> attribute on the model: </p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->published()
->get();
$unpublishedPosts = Article::query()
->notPublished()
->get();</code></pre>
<p> Another method is to use <code>booted</code> method in the model's <code>addGlobalScope</code> method: </p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$articles = Article::query()
->where('team_id', Auth::user()->team_id)
->get();</code></pre>
<p> Both methods apply the <code>where('team_id', Auth::user()->team_id)</code> constraints to all queries on the <code>AppModelsArticle</code> model. </p>
<p> This means you can now write queries without worrying about filtering by <code>team_id</code> column: </p>
<pre class="brush:php;toolbar:false"><code>php artisan make:scope TeamScope</code></pre>
<p>If we assume that the user belongs to a team with <code>team_id</code> being <code>1</code>, the following SQL will be generated for the above query: </p>
<pre class="brush:php;toolbar:false"><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);
}
}</code></pre>
<p>This is cool, right? ! </p>
<h3>#Anonymous global query scope</h3>
<p> Another way to define and apply a global query scope is to use an anonymous global scope. </p>
<p>Let's update our <code>AppModelsArticle</code> model to use anonymous global scope: </p>
<pre class="brush:php;toolbar:false"><code>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
{
// ...
}</code></pre>
<p> In the above code example, we used the <code>addGlobalScope</code> method to define anonymous global scope in the <code>booted</code> method of the model. <code>addGlobalScope</code> method accepts two parameters: </p>
<ul>
<li>The name of the scope - If you need to ignore it in your query, you can use this name to refer to the scope </li>
<li>Scope Constraints - Define the closure to apply to the constraints </li>
</ul>
<p> As with other methods, this applies the <code>where('team_id', Auth::user()->team_id)</code> constraint to all queries on the <code>AppModelsArticle</code> model. </p>
<p>In my experience, anonymous global scope is not as common as defining global scope in a separate class. But it is beneficial to know that they are available in case of emergencies. </p>
<h3>#Ignore global query scope</h3>
<p> Sometimes you may want to write a query that does not use the global query scope that has been applied to the model. For example, you might be building a report or analysis query that needs to contain all records regardless of the global query scope. </p>
<p>If this is the case, you can use one of two methods to ignore the global scope. </p>
<p>The first method is <code>withoutGlobalScopes</code>. This method allows you to ignore all global scopes on the model if no parameters are passed to it: </p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->where('published_at', '<', now())
->get();</code></pre>
<p> Or, if you want to ignore only a given set of global scopes, you can pass the scope name to the <code>withoutGlobalScopes</code> method: </p>
<pre class="brush:php;toolbar:false"><code>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();</code></pre>
<p> In the example above, we ignore <code>AppModelsScopesTeamScope</code> and another fictional anonymous global scope called <code>another_scope</code>. </p>
<p> Or, if you want to ignore only a single global scope, you can use the <code>withoutGlobalScope</code> method: </p>
<pre class="brush:php;toolbar:false"><code>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());
});
}
// ...
}</code></pre>
<h3>#Precautions for global query scope</h3>
<p>It is important to remember that the global query scope is only applied to queries made through the model. If you write database queries using the <code>IlluminateSupportFacadesDB</code> appearance, the global query scope is not applied. </p>
<p>For example, suppose you wrote this query and you want it to crawl only articles belonging to the team of logged in users: </p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->published()
->get();
$unpublishedPosts = Article::query()
->notPublished()
->get();</code></pre>
<p> In the above query, even if the global query scope of <code>AppModelsArticle</code> is defined on the <code>AppModelsScopesTeamScope</code> model, the scope will not be applied. Therefore, you need to make sure that constraints are applied manually in the database query. </p>
<h1>Test local query scope</h1>
<hr>
<p> Now that we have learned how to create and use query scopes, we will look at how to write tests for them. </p>
<p>There are several ways to test the scope of a query, and the method you choose may depend on your personal preference or the content of the scope you are writing. For example, you might want to write more unit-style tests for the scope. Alternatively, you might want to write more integration style tests that test scope in contexts like controllers. </p>
<p> Personally, I like to mix the two so I can be sure that the scope is adding the correct constraints and that the scope is actually being used in the query. </p>
<p>Let's start with the previous examples <code>published</code> and <code>notPublished</code> ranges and write some tests for them. We will need to write two different tests (one for each range): </p>
<ul>
<li>A test check<code>published</code> range returns only published articles. </li>
<li>A test check<code>notPublished</code> range returns only unpublished articles. </li>
</ul>
<p>Let's look at these tests and then discuss what's being done: </p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->where('published_at', '<', now())
->get();</code></pre>
<p> We can see in the above test file, we first create some data in the <code>setUp</code> method. We have created two published articles, one unscheduled article and one arranged article. </p>
<p> Then there is a test (<code>only_published_articles_are_returned</code>) that checks the <code>published</code> range to return only published articles. There is also a test (<code>only_not_published_articles_are_returned</code>) that checks the <code>notPublished</code> range to return only unpublished articles. </p>
<p> By doing this, we can now be sure that our query scope is applying constraints as expected. </p>
<h1>Test range in controller</h1>
<hr>
<p>As we mentioned, another way to test the scope of a query is to test them in the context used in the controller. While the isolation testing of scopes can help assert that scope is adding the correct constraints to the query, it doesn't actually test whether the scope is used in the application as expected. For example, you might forget to add a <code>published</code> range to a query in the controller method. </p>
<p> These types of errors can be captured by writing tests that asserts that return correct data when using scopes in controller methods. </p>
<p> Let's take the example of having a multi-tenant blog application and write a test for the controller method that lists the articles. Let's assume we have a very simple controller method, as follows: </p>
<pre class="brush:php;toolbar:false"><code>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();</code></pre>
<p>We assume that the <code>AppModelsArticle</code> model has applied our <code>AppModelsScopesTeamScope</code>. </p>
<p>We will assert that we only return articles that belong to the user team. The test case might look like this: </p>
<pre class="brush:php;toolbar:false"><code>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());
});
}
// ...
}</code></pre>
<p>In the above test, we are creating two teams. Then, we create a user belonging to Team One. We created 3 articles for Team 1 and 2 articles for Team 2. We then act as users and make a request to the controller method that lists the articles. The controller method should return only 3 articles belonging to Team One, so we assert that only those articles are returned by comparing the article IDs. </p>
<p> This means we can be sure that the global query scope is being used as expected in the controller method. </p>
<h1>Conclusion</h1>
<hr>
<p>In this article, we have learned about local query scope and global query scope. We learned the differences between them, how to create and use them, and how to write tests for them. </p>
<p>Hope you should now be able to use query scopes confidently in your Laravel application. </p>
The above is the detailed content of Learn to master Query Scopes in Laravel. For more information, please follow other related articles on the PHP Chinese website!