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