A Case For Custom Collections
dark mode
I recently submitted
a pull request to the Laravel framework to add a
new Eloquent attribute called CollectedBy
. In short, it allows you to use an attribute to
configure your Eloquent model to use a specific Collection class, rather than the default (which is
Illuminate\Database\Eloquent\Collection
).
Just to mention it: the concept of using custom collections is nothing new. You've been able to do
that for a while by overriding the model's newCollection
method. All the attribute does is let you
configure the collection class to use in a more declarative way, but hopefully this may make it a
bit more approachable.
As the PR was merged and I saw some questions popping up over at X, I figured I should write a few lines about the benefits (and drawbacks!) of using custom collections.
<?php namespace App\Models; use App\Models\Collections\PostCollection; use Illuminate\Database\Eloquent\Attributes\CollectedBy; use Illuminate\Database\Eloquent\Model; #[CollectedBy(PostCollection)] class Post extends Model { // ... }
Now, whenever you query the model and get multiple records back from the database, those records will end up in your custom collection:
$posts = Post::whereNotNull('published_at')->get(); var_dump($posts::class); // string(34) "App\Models\Collections\PostCollection"
So, why do you need custom collections anyway?
Custom collections for me is primarily about two things (that are essentially the same): encapsulating logic and communicating intent.
Laravel Collections are super powerful and is packed with great tools for us to use whenever we need to deal with collections of objects. Since you can use the default Eloquent collection class to make operations any kind of model, the class is – naturally – incredibly generic.
Having a generic collection class is obviously great, and is more than good enough in the vast majority of cases, but there are downsides to dealing with generic operations too. For example, generic operations are generally bad at communicating intent.
Now, it's worth to point out that generic or specific isn't necessarily a binary state. It all
depends on your viewpoint. When comparing the reduce
and map
methods on the collection object,
we can describe map
as a very specific operation while reduce
is a very generic one:
-
map
communicates its intent to us by promising that given a collection of items, it will return a new collection with the same amount of items with some sort of transformation applied to each item. -
reduce
communicates basically nothing. The promise is that we use a provided callback to reduce the collection into a new value. But that value may be a number, a string, an object or even a new collection.reduce
can even be used to implementmap
,sum
,filter
and any other method in the collection class (but that is a topic for another conversation).
Okay, so reduce
is generic and map
is specific. But just like map
is (theoretically)
encapsulating a specific way of using reduce
, we can add custom methods to our custom collection
to encapsulate specific ways of using map
. What if take our PostCollection
from above and add a
method called keywords
?
<?php class PostCollection extends Collection { public function keywords() { return $this->map(function (Post $post) { // apply some logic to strip away common words and identify the most // used words in the post body }); } }
So with this context in mind, which one is generic and which one is specific? The keywords
method
clearly communicates what it does to us, while map
is just a generic way to apply some
transformation to a collection.
$posts->keywords(); // 👌 $posts->map(fn (Post $post) => /** ... */); // 🤔
With the specific method added by our custom collection, we can identify right away what's happening.
Avoiding too many levels of indirection
Now, I need to point that there's a pretty good chance that using the base Eloquent collection meets
your needs. Taking the example above, there's a pretty good chance that you would actually implement
that keywords
method on your model, so the difference between encapsulating the collection method
and just mapping over it becomes very tiny.
$posts->map(fn (Post $post) => $post->keywords()); // 👌 $posts->keywords(); // 🤔
Instead, by adding the keywords
method we've introduced a level of indirection that may not even
pay off at all.
I mainly view custom collections as something for the primary models of larger applications where you end up mapping, filtering and suming the same things over and over again. In those cases, it can be quite nice to create named abstractions for you to use in your views or other parts of your app. For smaller applications, there's probably a good chance that I wouldn't bother.
In the end, you need to consider your own use case.
Some additional examples
Other things that I use custom collections for is encapsulating logic for different types of filters and summations. If you have a collection of products, maybe you would want methods for getting their total price (and different variants, such as with or without tax, with a coupon applied, etc.) Maybe you want methods for filtering out products of a specific category, or with a certain price, or products that are currently on sale. The custom collection is your oyster!
<?php namespace App\Models\Collections; class ProductCollection extends Collection { public function totalPrice(bool $includeVat = true) { return $this->sum( fn (Product $product) => $product->price + ($includeVat ? $product->vat : 0), ); } public function onSale() { $productsOnSale = Cache::remember( 'products-on-sale', fn () => app(SaleService::class)->getProductsOnSale(), ); return $this->filter( fn (Product $product) => $producstsOnSale->contains($product->id), ); } public function electronics() { return $this->filter( fn (Product $product) => $product->type === ProductType::Electronics, ); } }
And here are some additional methods on the PostCollection
for inspiration:
<?php namespace App\Models\Collections; class PostCollection extends Collection { public function totalWordCount() { return $this->sum(fn (Post $post) => str_word_count($post->body)); } public function published() { return $this->filter(fn (Post $post) => $post->published_at !== null); } }