Skip to content

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:

php
->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.

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

php
->tap($this->joins(...))

is effectively the same as:

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

php
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(...)) // ArgumentCountError

tap() 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:

php
->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.