Using PHP's first-class callables with Laravel's tap()
Published on: August 25, 2026
A while back, I wrote about using tap() to organize large Laravel queries.
The idea was simple: instead of mixing relationships, joins, and filters into one big query method, use tap() to group them into separate, well-named methods without breaking the fluent chain.
Since then, I found a small variation worth sharing: passing methods to tap() using PHP's first-class callable syntax instead of wrapping them in a closure.
A quick recap
In the original approach, tap() took a closure, and the closure called the other methods:
->tap(function (Builder $q) {
$this->joins($q);
$this->filters($q);
})This works well and is still a perfectly good way to do it, especially when you want to group several method calls into a single tap().
Using first-class callable syntax
When each tap() only needs to call a single method, PHP 8.1's first-class callable syntax lets you skip writing the closure and pass the method straight in.
public function get(): Collection
{
return Crew::query()
->select($this->select())
->where(function (Builder $q) {
$q->active()
->alive()
->noWithdrawal();
})
->tap($this->relationships(...))
->tap($this->joins(...))
->tap($this->filters(...))
->orderByRaw('
crews.last_name,
crews.first_name,
crews.middle_name
')
->get();
}
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'));
}
//...
}So this:
->tap($this->joins(...))is effectively the same as:
->tap(function (Builder $q) {
$this->joins($q);
})The first-class callable syntax wraps the method in a closure, which tap() can then use as its callback.
It's a small improvement, but it removes some boilerplate while keeping the query easy to scan.
Watch out for extra arguments
relationships(), joins(), and filters() all get away with $this->method(...) because none of them need anything beyond the builder. If one of them did — say filters() needed a RequestFilters value passed down from the caller — it breaks:
private function filters(Builder $q, RequestFilters $filters): void
{
if ($filters->has('region_id')) {
$q->where('crew_addresses.region_id', $filters->get('region_id'));
}
}
->tap($this->filters(...)) // ArgumentCountErrortap() calls its callback with exactly one argument — the builder. Since first-class callable syntax just copies the method's signature as-is, there's nothing to fill the second parameter with, and PHP throws an error.
An inline closure handles it without issue:
->tap(fn (Builder $q) => $this->filters($q, $filters))So the syntax is a nice shortcut for any method that only needs the builder — but the moment one needs more, you're back to a closure.
If you want to learn more about PHP's first-class callable syntax, PHP Watch has a good overview, including its limitations and edge cases.