A Case For Custom Collections

Posted on 2024-10-16 · 5 minute read
Laravel
Eloquent
Collections

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 implement map, 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);
    }
}