Laravel's higher order collection proxies
September 1, 2024
Higher-order collection proxies are a feature of Laravel that allows you to proxy methods to the underlying objects in a collection.
What the heck does that even mean?
Let's look at this simple example where we pull blocked users out of the database and send them an email informing them that they've been blocked.
Users::where('blocked', 1)->get()->each(function(User $user) { $user->sendBlockedEmail();});
This is a very simple implementation. We could clean it up by dropping the get
, because each
is implemented on the Eloquent Builder.
Users::where('blocked', 1)->each(function(User $user) { $user->sendBlockedEmail();});
However, get
returns a Collection while the Builder's each
does not. We want a Collection, so we'll restore the get
and then call each
on the Collection instead of the query builder.
Users::where('blocked', 1)->get()->each(function(User $user) { $user->sendBlockedEmail();});
This is where Higher Order Collection Proxies come in. Instead of calling a method named each
, we're just going to chain a property named each. Like this:
Users::where('blocked', 1)->get()->each;
This is a magic property. If we were to dd
the result here, we'd get back an instance of Illuminate\Support\HigherOrderCollectionProxy
. (You can see a list of all these magic proxy properties here.)
The magic here is that any method you call on the proxy will be proxied (!) to the underlying collection item. So revisiting our initial example, here's what it would look like now:
Users::where('blocked', 1)->get()->each->sendBlockedEmail();
sendBlockedEmail
is actually being called on each underlying User
object. The neat part is that you can continue to chain collection methods on after that!
Users::where('blocked', 1)->get() ->each->sendBlockedEmail() // Another collection method ->map(function(User $user) { // ... })
This can provide a super terse, readable collection chain. But! That's not all!
Advanced higher order collection proxies
Let's look at another example. This time using Laravel Dusk's browser automation.
Browser automation is a powerful tool for testing web applications. It allows you to simulate user interactions with your application, such as clicking buttons, filling out forms, and navigating between pages. Laravel Dusk is a browser automation tool that makes it easy to write and run browser tests for your Laravel applications.
We'll take a look at how you can use Laravel's higher-order collection proxies to make your code more readable and concise.
(Prefer to watch instead of read? Check out the video version of this article on YouTube: Poppin Off: Higher order collection proxies.)
In this example, suppose you are opening two different browsers in your test, doing the same thing in each browser to get it set up, and then doing different things. You could write this code like this:
public function testExample(): void{ $this->browse(function (Browser $browser1, Browser $browser2) { $browser1->resize(624, 696); $browser1->move(12, 12); $browser2->resize(624, 696); $browser2->move(644, 12); $browser1->visit('https://reverb.laravel.com'); $browser1->waitFor('figure'); $browser1->scrollIntoView('figure'); $browser2->visit('https://reverb.laravel.com'); $browser2->waitFor('figure'); $browser2->scrollIntoView('figure'); Sleep::for(10)->seconds(); }}
In the example test we have two browsers, $browser1
and $browser2
, that we are setting up in the same way. We are
resizing and moving the browsers, visiting a URL, waiting for an element to appear, and scrolling to that element.
What I don't like here is that we're doing the same thing in each browser to prepare it.
Here's how we can make this a little more simplified:
Let's first collect $browser1
and $browser2
into a collection and save it to a variable called $both
. Then we'll
do dd($both)
to see what we have.
public function testExample(): void{ $this->browse(function (Browser $browser1, Browser $browser2) { $both = collect([$browser1, $browser2]); dd($both); $browser1->resize(624, 696); $browser1->move(12, 12); $browser2->resize(624, 696); $browser2->move(644, 12); $browser1->visit('https://reverb.laravel.com'); $browser1->waitFor('figure'); $browser1->scrollIntoView('figure'); $browser2->visit('https://reverb.laravel.com'); $browser2->waitFor('figure'); $browser2->scrollIntoView('figure'); Sleep::for(10)->second(); }}
Now when we run the test, we have two browsers that open up. From the terminal output, we can see that $both
is a
collection with two items in it, $browser1
and $browser2
.
What if we just added each
to the end of the collect statement? And this is not an each callback each()
but
just each
.
public function testExample(): void{ $this->browse(function (Browser $browser1, Browser $browser2) { $both = collect([$browser1, $browser2])->each; $dd($both); $browser1->resize(624, 696); $browser1->move(12, 12); $browser2->resize(624, 696); $browser2->move(644, 12); $browser1->visit('https://reverb.laravel.com'); $browser1->waitFor('figure'); $browser1->scrollIntoView('figure'); $browser2->visit('https://reverb.laravel.com'); $browser2->waitFor('figure'); $browser2->scrollIntoView('figure'); Sleep::for(10)->second(); }}
We run the test again and now we have an instance of HigherOrderCollectionProxy
. And instead of using it right away, we just assign it to a variable!
We can call the duplicate actions on the HigherOrderCollectionProxy
, $both
, and it will apply the actions to each
browser in the collection.
public function testExample(): void{ $this->browse(function (Browser $browser1, Browser $browser2) { $both = collect([$browser1, $browser2])->each; $browser1->resize(624, 696); $browser2->resize(624, 696); $both->resize(624, 696); // We still have access to the individual browsers! $browser1->move(12, 12); $browser2->move(644, 12); $browser1->visit('https://reverb.laravel.com'); $browser2->visit('https://reverb.laravel.com'); $both->visit('https://reverb.laravel.com'); $browser1->waitFor('figure'); $browser2->waitFor('figure'); $both->waitFor('figure'); $browser1->scrollIntoView('figure'); $browser2->scrollIntoView('figure'); $both->scrollIntoView('figure'); Sleep::for(10)->second(); }}
All cleaned up, it's a lot shorter and more readable:
public function testExample(): void{ $this->browse(function (Browser $browser1, Browser $browser2) { $both = collect([$browser1, $browser2])->each; $both->resize(624, 696); // We still have access to the individual browsers! $browser1->move(12, 12); $browser2->move(644, 12); $both->visit('https://reverb.laravel.com'); $both->waitFor('figure'); $both->scrollIntoView('figure'); Sleep::for(10)->second(); }}
Typehinting
If you don't like the fact that you lose the underlying type, you can typehint the Proxy:
public function testExample(): void{ $this->browse(function (Browser $browser1, Browser $browser2) { /** @var \Laravel\Dusk\Browser $both */ $both = collect([$browser1, $browser2])->each; $both->resize(624, 696); // We still have access to the individual browsers! $browser1->move(12, 12); $browser2->move(644, 12); $both->visit('https://reverb.laravel.com'); $both->waitFor('figure'); $both->scrollIntoView('figure'); Sleep::for(10)->second(); }}
When we run the test, the HigherOrderCollectionProxy
is going to proxy under to every item in the collection. So it
will run resize on all the items in the collection, for example.
We've simplified our code and made it more readable by using Laravel's higher-order collection proxies. It's also more maintainable, because if we want to change how we do something in both browsers, we only have to change it in one spot instead of two. Now imagine you've got 10 items in the collection and you'll see how valuable that is!
YouTube video
Check out the video version of this article on YouTube!