In this article, I want to go over how to use tappable scopes in Laravel. I’ve used similar patterns in Java Spring Boot, but never really considered using it in Laravel until I read Unorthodox Eloquent by Muhammed Sari which is an excellent guide to many advanced features in Laravel Eloquent.
Typically, when using query scopes in Laravel, the simple way is to use the scope prefix on a method in the model, like the following:
{
public function scopePublished(Builder $query): void
{
$query->where(‘published_at’, ‘<=’, now());
}
}
$publishedPosts = Posts::published()->get();
This works well, but it does make it harder for the IDE to handle unless you’re using something like the Laravel IDE Helper package.
To convert this into a tappable scope, we can do something like the following:
class Published
{
public function __invoke(Builder $query): void
{
$query->where(‘published_at’, ‘<=’, now());
}
}
$publishedPosts = Posts::tap(new Published)->get();
Using the tappable scope changes the following:
$publishedPosts = Posts::published()->get();
// With tappable scope
$publishedPosts = Posts::tap(new Published)->get();
The top one looks nicer, however, the IDE will not be able to easily see what the published method does since it using the magic scope prefix, whereas with the tappable scope version, you can easily click into Published and see exactly what’s happening.
Also, using the tappable scope allows it to be easily reused. For example, if you had a Comment model, that also included a published_at column, then to get just published comments, you can use the same scope from before:
Now, let’s take these scopes to the next level by adding custom parameters.
Using are same Post and Comment models, let’s assume both include a user_id field. To handle that with a tappable scope, create the following:
class ByUser
{
public function __construct(private readonly int $userId)
{
}
public function __invoke(Builder $query): void
{
$query->where(‘user_id’, $this->userId);
}
}
With the new tappable scope, we can fetch posts and comments for a user shown below:
$posts = Post::tap(new ByUser($userId))->get();
$comments = Comment::tap(new ByUser($userId))->get();
The above examples are relatively simple, and maybe it’s easier to just use normal where methods for those, so maybe they are not the best cases for tappable scopes, but I wanted to use the simple examples as an introduction. Now let’s create a tappable scope for something a little more complex.
On our home page, we want to show the latest published posts with the author and comment count. This query could look like the following:
->with([‘user’, ‘comments’])
->where(‘published_at’, ‘<=’, now())
->latest(‘published_at’)
->limit(10)
->get();
This works, but the query is starting to get kind of large. We also fetch the entire user model for each post and all the comments, when really all we want is a name and count. Also, we are counting unpublished comments which we don’t want. So let’s adjust:
->select(‘posts.*’)
->join(‘users’, ‘users.id’, ‘=’, ‘posts.user_id’)
->withAggregate(‘user’, ‘name’)
->withCount([‘comments’ => fn (Builder $query) => $query->where(‘published_at’, ‘>=’, now())])
->where(‘published_at’, ‘<=’, now())
->latest(‘published_at’)
->limit(10)
->get();
This gives us exactly what we want, an array of posts with the following structure:
0 => [
‘id’ => 69,
‘user_id’ => 360,
‘name’ => ‘…’,
‘body’ => ‘…’,
‘published_at’ => ‘2024-04-20 03:18:37’,
‘created_at’ => ‘2024-04-21T18:44:24.000000Z’,
‘updated_at’ => ‘2024-04-21T18:44:24.000000Z’,
‘user_name’ => ‘Janae Luettgen’,
‘comments_count’ => 2,
],
1 => […]
…
]
This is great, but now our query is pretty complex. Imagine different parts of our application need to show a limit of 5 posts instead of 10. Or maybe we want to only find a count of unpublished comments. Let’s create a tappable scope:
class LatestPosts
{
public function __construct(private readonly int $limit = 10, private readonly bool $publishedComments = true)
{
}
public function __invoke(Builder $query): void
{
$query->select(‘posts.*’)
->join(‘users’, ‘users.id’, ‘=’, ‘posts.user_id’)
->withAggregate(‘user’, ‘name’)
->withCount([
‘comments’ => fn (Builder $query) => $query
->when(
$this->publishedComments,
fn(Builder $query) => $query->where(‘published_at’, ‘>=’, now())
)
]
)
->where(‘published_at’, ‘<=’, now())
->latest(‘published_at’)
->limit($this->limit);
}
}
Now, instead of having to copy and paste this large query wherever we need it, it is encapsulated in a single place and we can fetch our latest posts like below:
I hope this helps you in your Laravel career. It’s a clean way to remove some of the magic of the built-in Laravel query scopes and allows for easy reuse and abstracting complex queries.
Thanks for reading!
Related Links