Elevate Your Laravel Eloquent Queries with Tappable Scopes
Tags: webdev, php, laravel, database
Published: 2024-04-23 11:57:14

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](https://muhammedsari.me/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:

```php
class Posts 
{		
	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](https://github.com/barryvdh/laravel-ide-helper).

To convert this into a tappable scope, we can do something like the following:

```php
// app/Scopes/Published.php

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:

```php
// With regular query scope
$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:

```php
$comments = Comment::tap(new Published)->get();
```

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:

```php
// app/Scopes/ByUser.php

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:

```php
$userId = 1;

$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:

```php
$posts = Post::query()
    ->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:

```php
$posts = Post::query()
    ->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:

```php
[
    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:

```php
// app/Scopes/LatestPosts.php

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:

```php
$latestPosts = Post::tap(new LatestPosts(limit: 10, publishedComments: true))->get();
```

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

* [Unorthodox Eloquent - Muhammed Sari](https://muhammedsari.me/unorthodox-eloquent)
    
* [Laravel IDE Helper](https://github.com/barryvdh/laravel-ide-helper)
    
* [Laravel - Local Scopes](https://laravel.com/docs/11.x/eloquent#local-scopes)