paulund

Custom Collections

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();
    }
}