As yet undocumented, Laravel 5.3 quietly added a new tap()
global helper function, improving the framework’s declarative capabilities. It’s a subtle idiom picked up from Ruby and Lodash that allows you to “tap” into a chain.
You will not use this function often. It does take control of stodgy imperative solutions with right-to-left assignments souring solutions in an unpleasant direction. The end result will be saving 2 or 3 lines on each use while making that code read top-down, left-to-right.
tap()
to perform intermediate actions
Starting with a simple example I’ve grabbed some code from Laravel’s AuthenticateSession@handle()
. Veteran PHP developers are plenty familiar with the imperative solution:
$response = $next($request);
$this->storePasswordHashInSession($request);
return $response;
A Laravel 5.4 declarative refresh?
return tap($next($request), function () use ($request) {
$this->storePasswordHashInSession($request);
});
Some devs might be cringing as that use
syntactical bloat but most of us know what the PHP closure dream is:
return tap($next($request), () -> {
$this->storePasswordHashInSession($request);
});
Pattern:
// assign object
// make call using object (or not even using that object!)
// return object
tap()
to restore state
Eloquent create
and update
methods support a ['timestamps' => false]
option. If this were implemented as a chainable method, the result is fairly readable.
// Method defined in App\Model that App\Message extends.
public function keepingTimestamps(callable $callback)
{
try {
$timestamps = $this->timestamps;
$this->timestamps = false;
return tap($this, $callback);
} finally {
$this->timestamps = $timestamps;
}
}
Now that Message
model object can be chained:
request()->user()->latestMessage->keepingTimestamps(function ($message) {
$message->markRead(); // updates a 'read_at' timestamp instead
}); // returns the latestMessage
If you recognize this code has the same pattern of DB::transaction()
, Laravel 5.4 rewrote the method to use tap()
.
Pattern:
// capture state
// make call using object
// restore state
// return that object
Collection tap()
Laravel 5.4 also made its Collection
class tappable, which will expose the full power of this approach. Now arbitrary code can be run mid-pipeline without having to break the chain. For Laravel 5.3 or below, copy and paste the 5 line method as a Collection macro into your project’s AppServiceProvider@boot()
.
Here’s one example I used for a Laravel web site supporting both English and French Canadian locales. Instead of hardcoding months into language translation files, I used Carbon
for these month of the year dropdown <option>
s. Hence this model decorator method:
public function monthOptions()
{
return collect(range(1, 12))
->keyByValue() // custom Collection macro
->tap(function () {
if (App::getLocale() === 'fr') {
setlocale(LC_TIME, 'fr_CA');
}
})
->map(function ($month) {
return sprintf('%02d - %s', $month,
Carbon::now()->month($month)->formatLocalized('%B'));
})
->tap(function () {
if (App::getLocale() === 'fr') {
setlocale(LC_TIME, '');
}
});
}
With just one extra language, I’m happy with this solution. For more language support, the set/restore states would be extracted into other methods to make this even more readable.
Collection tap()
to handle console command progress bars
This is the most typical use case I’ve had in collections and it makes commands some of the cleanest code in my projects. You can even keep its method chain for the full handle()
duration.
public function handle()
{
Club::findOrFail($this->option('club'))
->members()
->subscribed()
->get()
->tap(function ($members) {
$this->output->progressStart($members->count());
})
->each(function ($member) {
Mail::to($member)->queue(new Newsletter($member, $this->matchReport());
$this->output->progressAdvance();
})
->tap(function () {
$this->output->progressFinish();
});
}
public function matchReport()
{
return once(function () {
return MatchReport::ofRound($this->option('round'))->firstOrFail();
});
}
Laravel Query Builder
tap()
macro
Lastly, what if you wanted a progress bar when the eager-loaded Eloquent memory footprint of that collection pipeline is too high? chunk()
the result into pages and use a query extension macro to accomplish the same chaining abilities.
public function handle()
{
Club::findOrFail($this->option('club'))
->players()
->active() // let's say this scope sorts players to most recently active
->with([
'awards',
'seasons.matches',
'teams',
])
->tap(function ($query) {
$this->output->progressStart(with(clone $query)->reorder()->count());
})
->chunk(100, function ($players) {
$players->each(function ($player) {
with(new PerformanceReport($player))->save();
});
$this->output->progressAdvance();
})
->tap(function () {
$this->output->progressFinish();
});
}