paulund

Custom Collections

Laravel's Collection class is already powerful, but there are times when your domain logic benefits from its own dedicated collection. Custom collections let you attach business-specific behaviour to a group of related objects, keeping that logic out of controllers and services.

Extending the Base Collection

Create a class that extends Illuminate\Support\Collection and add methods that make sense for your domain:

namespace App\Collections;

use Illuminate\Support\Collection;
use App\Models\Order;

class OrderCollection extends Collection
{
    public function totalRevenue(): float
    {
        return $this->sum('total');
    }

    public function completedOrders(): self
    {
        return new self($this->filter(fn(Order $order) => $order->status === 'completed'));
    }

    public function averageOrderValue(): float
    {
        return $this->isEmpty() ? 0.0 : $this->totalRevenue() / $this->count();
    }

    public function groupByCustomer(): array
    {
        return $this->groupBy('customer_id')->map(fn(Collection $orders) => new self($orders));
    }
}

Returning Custom Collections from Eloquent

Tell your model to use your custom collection by overriding the newCollection method:

namespace App\Models;

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

class Order extends Model
{
    public function newCollection(array $models = []): OrderCollection
    {
        return new OrderCollection($models);
    }
}

Now every time you call Order::get() or access a hasMany relationship, you get an OrderCollection back:

$orders = Order::where('status', 'completed')->get();

// These methods are available directly on the result
$revenue = $orders->totalRevenue();
$average = $orders->averageOrderValue();

A Practical Example

Suppose you are building a reporting page that displays invoice statistics. Without a custom collection, the logic lives scattered across a controller or a service class:

// ❌ Without a custom collection — logic spread across the controller
$invoices = Invoice::where('issued_at', '>=', now()->subMonth())->get();
$total    = $invoices->sum('amount');
$paid     = $invoices->filter(fn($i) => $i->status === 'paid');
$overdue  = $invoices->filter(fn($i) => $i->status === 'overdue');

With a custom collection, that same logic is encapsulated:

// ✅ With a custom collection — clean and reusable
$invoices = Invoice::where('issued_at', '>=', now()->subMonth())->get();

$total   = $invoices->totalAmount();
$paid    = $invoices->paid();
$overdue = $invoices->overdue();

When to Use Custom Collections

  • When you find yourself writing the same filtering or aggregation logic in multiple places.
  • When a group of models has business rules that apply to the collection as a whole, not just individual items.
  • When you want to make your code more readable by giving common operations meaningful names.

When Not to Use Them

  • For one-off operations. A single ->filter() call in a controller does not warrant a custom collection.
  • When the logic is already well-served by Eloquent scopes or query builders. Custom collections operate on already-fetched data; scopes operate at the database level.

Tips

  • Return new self(...) from your custom collection methods so that chaining continues to return your custom type.
  • Type-hint your collection in method signatures to get IDE autocompletion:
class InvoiceService
{
    public function getMonthlyInvoices(): InvoiceCollection
    {
        return Invoice::where('issued_at', '>=', now()->subMonth())->get();
    }
}