Domain-Driven Laravel: Encapsulating Business Logic in Custom Collections

How to use Custom Collections in Laravel

Domain-Driven Laravel: Encapsulating Business Logic in Custom Collections


When working with Laravel Eloquent you can't just retrieve a list of models using something like Order::all() or User::where('active', true)->get() and expect it to return a PHP array. Instead, Eloquent will wrap the resulting models inside an instance of Illuminate\Database\Eloquent\Collection

Laravel default Collection class comes with a variety of really useful helper methods such as map(), filter(), pluck() or groupBy(). But as your application grows you will see that a lot of domain logic related to multiple models is being repeated in controllers, blade templates or services. It's really common to have chains of collection methods in multiple places in your code calculating order subtotals, filtering out delinquent subscriptions or any other domain specific logic.

In this post I'll show you how to harness the power of Laravel collections to organize and encapsulate this type of logic. We'll learn how to create custom collection classes and instruct Eloquent to use them when retrieving models.

Laravel allows us to create our own implementation of Collection by overriding the static getCollectionClass method on our Eloquent models and telling Eloquent which class we want to use for that specific model.

The Problem: Leaky Collection Logic

Let's imagine a common use case in an e-commerce or invoice application. You probably have an Order resource, which holds a collection of items.

In order to compute a total price, taxes, applicable discounts, you may end up with code similar to the following inside your controller or blade view

// Inside a Controller or Action
$items = $order->items;

// Calculating subtotal
$subtotal = $items->sum(function ($item) {
    return $item->price * $item->quantity;
});

// Filtering shippable items
$shippableItems = $items->filter(function ($item) {
    return ! $item->is_digital && $item->in_stock;
});

// Calculating total tax
$tax = $items->where('is_taxable', true)->sum(function ($item) {
    return ($item->price * $item->quantity) * ($item->tax_rate / 100);
});


What would you do if you needed to calculate these same values in a PDF invoice generator, an email notification, and an API resource? You could copy and paste those closures or create a bunch of bulky helper utility classes.

That transformation logic for collections shouldn't be scattered around in your code base. It should be on the collection of items itself.

Step 1: Creating the Custom Collection Class

A custom collection is simply a class that extends Illuminate\Database\Eloquent\Collection. You can place these classes inside an app/Collections directory.

Let's create an OrderItemCollection class:

namespace App\Collections;

use Illuminate\Database\Eloquent\Collection;

class OrderItemCollection extends Collection
{
    // Calculate the gross subtotal of all items in the collection.
    public function subtotal(): float
    {
        return (float) $this->sum(fn ($item) => $item->price * $item->quantity);
    }

    // Filter and return only items that require physical shipment.
    public function physical(): self
    {
        return $this->filter(fn ($item) => ! $item->is_digital && $item->in_stock);
    }

    // Calculate the total tax amount across taxable items.
    public function totalTax(): float
    {
        return (float) $this->where('is_taxable', true)
            ->sum(fn ($item) => ($item->price * $item->quantity) * ($item->tax_rate / 100));
    }

    // Calculate total weight for shipping estimation.
    public function totalWeight(): float
    {
        return (float) $this->physical()->sum(fn ($item) => $item->weight * $item->quantity);
    }
}


Notice how methods like physical() return self, allowing you to chain custom domain methods seamlessly.

Step 2: Linking the Custom Collection to the Model

By default, Eloquent models instantiate Laravel's base collection. To tell Eloquent to use your custom collection instead, override the newCollection() method on your model:

namespace App\Models;

use App\Collections\OrderItemCollection;
use Illuminate\Database\Eloquent\Model;

class OrderItem extends Model
{
    protected $fillable = [
        'order_id',
        'name',
        'price',
        'quantity',
        'weight',
        'is_digital',
        'is_taxable',
        'tax_rate',
    ];

    /**
     * Create a new Eloquent Collection instance.
     *
     * @param  array<int, \Illuminate\Database\Eloquent\Model>  $models
     * @return \App\Collections\OrderItemCollection
     */
    public function newCollection(array $models = []): OrderItemCollection
    {
        return new OrderItemCollection($models);
    }
}


That's all the configuration needed. From this point forward, every query that retrieves multiple OrderItem records will automatically return your custom OrderItemCollection.

Step 3: Using the Custom Collection in Practice

Now, look at how expressive and readable your business logic becomes across controllers, jobs, and services:

namespace App\Http\Controllers;

use App\Models\Order;
use Illuminate\Http\JsonResponse;

class OrderSummaryController extends Controller
{
    public function show(Order $order): JsonResponse
    {
        // Eloquent returns OrderItemCollection automatically via relations!
        $items = $order->items;

        return response()->json([
            'subtotal'       => $items->subtotal(),
            'tax'            => $items->totalTax(),
            'shipping_weight'=> $items->totalWeight(),
            'physical_count' => $items->physical()->count(),
            'total'          => $items->subtotal() + $items->totalTax(),
        ]);
    }
}


The code is self-documenting, reusable, and completely decoupled from controller or presentation layers.

Real-World Example 2: Managing User Status and Subscriptions

Custom collections are not limited to math and financial calculations. They are equally powerful for status filtering and bulk domain actions.

Consider a UserCollection:

namespace App\Collections;

use Illuminate\Database\Eloquent\Collection;

class UserCollection extends Collection
{
    // Filter users who have verified their email addresses.
    public function verified(): self
    {
        return $this->filter(fn ($user) => ! is_null($user->email_verified_at));
    }

    // Filter users with an active premium subscription.
    public function premium(): self
    {
        return $this->filter(fn ($user) => $user->is_premium && $user->subscription_ends_at?->isFuture());
    }

    // Extract a comma-separated list of recipient emails for batch mailers.
    public function toMailingList(): array
    {
        return $this->verified()
            ->pluck('email', 'name')
            ->toArray();
    }
}


Register it in your User model:

namespace App\Models;

use App\Collections\UserCollection;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    public function newCollection(array $models = []): UserCollection
    {
        return new UserCollection($models);
    }
}


Now in your notification commands or queues:

$users = User::all();

// Clean, readable domain operations
$recipients = $users->premium()->toMailingList();


Custom Collection vs. Model Method vs. Query Scope

Developers often ask when to use a Custom Collection versus a Query Scope or a Model Method. Here is a clear breakdown:

Concept Operates On Execution Level Typical Use Case
Model Method Single model instance PHP Memory $orderItem->isTaxable()
Query Scope SQL Query Builder Database (SQL) OrderItem::taxable()->get()
Custom Collection Set of hydrated models PHP Memory $order->items->subtotal()

The Thumb Rule: 

If you need to filter down large datasets before loading them, use a Query Scope in SQL. If you already have a loaded collection of records in memory and need to compute totals, aggregate metrics, or perform multi-model transformations, use a Custom Collection.

Important Things to Remember

  • 📌 Preserve type hinting on transforms: When returning filtered subsets of your custom collection, make sure to return self or static so that further chaining of your collection's methods is preserved.
  • ⚡ Relationships will use your custom collection: If you have a Post which hasMany Comment, and you decide to implement your custom collection for the Comment model, then $post->comments() will return an instance of your custom collection.
  • ⚠️ Do not perform database queries or other heavy operations inside your collection: A collection represents a set of models that have already been loaded into your application. It is not advisable to query the database or make any other expensive operations while operating on a collection. You might find yourself creating unexpected N+1 queries by accident.

Conclusion

Custom Eloquent Collections are one of the most elegant ways Laravel provides for domain-driven refactoring. By extracting multi-model aggregations and filters out of your controllers and into dedicated collection classes you keep your models lean, avoid writing repetitive closures and make your business logic much more easily testable at the unit level.

Thank you for reading this article 😊

For any query, do not hesitate to comment 💬


Previous Post Next Post

Contact Form