Organizing large Laravel queries with tap()
Published on: July 18, 2026
Large Laravel queries can become difficult to read. As the query grows, it is common to have relationships, joins, filters, and other conditions all mixed together in one method. Eventually, the query becomes difficult to read and understand.
There are many ways to organize a large Laravel query. You could extract parts of the query into reusable local scopes, create reusable query components, or move the query into a dedicated query class or repository.
These approaches can all be useful depending on the situation. But sometimes, you don't need another abstraction. Sometimes, you simply want to keep the query together while making the code easier to read.
This is where Laravel's tap() method can be useful as a way to group related query modifications without breaking the fluent chain.
The Idea
Laravel's tap() method lets you pass the current query builder to a closure, perform additional operations on it, and then continue chaining on the same builder.
For example:
private function get(): Collection
{
return Crew::query()
->where('active', true)
->tap(function (Builder $q) {
$this->filters($q);
$this->joins($q);
})
->get();
}
private function filters(Builder $q): void
{
if (/* ... */) {
$q->whereNull('deleted_at');
}
//...
}
private function joins(Builder $q): void
{
//...
}The query is still built using the same query builder instance and remains part of the same fluent chain. tap() gives us a place to group related query modifications without interrupting the chain.
This becomes useful when the query gets larger.
Real-World Example
Here is an example from a real project.
Instead of putting relationships, joins, and filters directly inside the get() method, I use tap() to group eager-loaded relationships, joins, and filters:
public function get(): Collection
{
return Crew::query()
->select($this->select())
->where(function (Builder $q) {
$q->active()
->alive()
->noWithdrawal();
})
->tap(function (Builder $q) {
$this->relationships($q);
$this->joins($q);
$this->filters($q);
})
->orderByRaw('
crews.last_name,
crews.first_name,
crews.middle_name
')
->get();
}The get() method is now easier to read because the implementation details are moved into separate methods.
Those methods contain the actual query logic:
private function relationships(Builder $q): void
{
$q->with([
'rank:id,alias',
//...
]);
}
private function joins(Builder $q): void
{
$q->join('crew_addresses', function (JoinClause $qq) {
$qq->on('crew_addresses.crew_id', '=', 'crews.id');
//...
});
//...
}
private function filters(Builder $q): void
{
if ($this->filters->has('region_id')) {
$q->where('crew_addresses.region_id', $this->filters->get('region_id'));
}
//...
}All of these methods still modify the same query builder. The tap() simply gives us a clean place to organize these sections:
->tap(function (Builder $q) {
$this->relationships($q);
$this->joins($q);
$this->filters($q);
})Final Thoughts
There are many ways to organize large Laravel queries, and tap() is just one of them.
You don't always need another abstraction to organize a large query. Sometimes, you can keep the query together and use tap() to group its different parts.
The main query stays easy to read, while the more complicated relationships, joins, and filters are moved into their own methods.
For me, that's the main value of this pattern. The query stays as a single query while, at the same time, the code becomes easier to read, understand, and maintain.
Update: Since publishing this article, I've found a more concise way to use this pattern with PHP's first-class callable syntax. Read the follow-up →