Contextual Macros and Mixins: Extending Laravel Collections Without Bloat
#laravel #collections #macros #php

Contextual Macros and Mixins: Extending Laravel Collections Without Bloat

3 min read Mohamed Said Mohamed Said

Why Extend Collections at All?

Laravel's Collection class covers the 90 % case, but every domain has its own vocabulary. Repeating ->filter(fn($u) => $u->isActive())->values() across ten service classes is a smell. Macros and mixins let you encode that vocabulary once and test it in isolation.


Macros: The Quick Win

Collection uses the Macroable trait, so you can attach a closure at boot time:

// app/Providers/CollectionServiceProvider.php

use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;

class CollectionServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Collection::macro('active', function (): Collection {
            /** @var Collection $this */
            return $this->filter(fn($item) => $item->is_active)->values();
        });

        Collection::macro('keyedById', function (): Collection {
            return $this->keyBy('id');
        });
    }
}

Register the provider in bootstrap/providers.php (Laravel 11+) and you can write:

$users->active()->keyedById();

The closure's $this is the collection instance — no static tricks needed.


Mixins: Organising Many Macros

Once you have more than a handful of macros, a mixin class keeps things tidy. Each public method returns a Closure:

// app/Collections/UserCollectionMixin.php

class UserCollectionMixin
{
    public function active(): Closure
    {
        return function (): Collection {
            return $this->filter(fn($u) => $u->is_active)->values();
        };
    }

    public function admins(): Closure
    {
        return function (): Collection {
            return $this->filter(fn($u) => $u->role === 'admin')->values();
        };
    }

    public function totalRevenue(): Closure
    {
        return function (): int|float {
            return $this->sum('revenue_cents') / 100;
        };
    }
}

Register it with one line:

Collection::mixin(new UserCollectionMixin());

IDE support is the catch. Add a @mixin docblock or generate an IDE helper via barryvdh/laravel-ide-helper to keep autocomplete intact.


Typed Domain Collections

For stricter guarantees, extend Collection directly and override offsetSet:

// app/Collections/OrderCollection.php

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

/**
 * @extends Collection<int, Order>
 */
class OrderCollection extends Collection
{
    public function pending(): static
    {
        return $this->filter(fn(Order $o) => $o->status->isPending())->values();
    }

    public function totalGross(): int
    {
        return $this->sum('gross_amount_cents');
    }
}

Tell Eloquent to use it on the model:

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

Now Order::where(...)->get() returns an OrderCollection automatically — no casting required at the call site.


Higher-Order Proxies

Laravel ships higher-order proxies for a fixed set of methods (map, filter, each, etc.). You cannot add new proxy targets, but you can combine them with your macros cleanly:

$orders->pending()->each->markAsProcessing();
// equivalent to
$orders->pending()->each(fn(Order $o) => $o->markAsProcessing());

The proxy delegates the method call to every item in the collection — useful for side-effect pipelines.


Testing Your Extensions

Macros and typed collections are trivial to unit-test with Pest:

it('filters active users', function () {
    $users = collect([
        (object) ['is_active' => true],
        (object) ['is_active' => false],
    ]);

    expect($users->active())->toHaveCount(1);
});

it('returns an OrderCollection from eloquent', function () {
    $orders = Order::factory(3)->create();
    expect(Order::all())->toBeInstanceOf(OrderCollection::class);
});

Keep macro registration in a service provider so tests that boot the application pick it up automatically.


Key Takeaways

  • Use macros for one-off, cross-domain helpers; use mixins to group related macros by domain.
  • Use typed collection subclasses when you want static analysis, strict typing, and IDE autocomplete without extra packages.
  • Register everything in a dedicated CollectionServiceProvider — not AppServiceProvider — to keep boot logic focused.
  • Higher-order proxies work seamlessly alongside custom macros for expressive side-effect pipelines.
  • Write a Pest unit test for every macro; they are pure functions and test in milliseconds.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do Collection macros affect Eloquent's LazyCollection?
No. `LazyCollection` is a separate class that also uses `Macroable`, so you must register macros on it independently with `LazyCollection::macro(...)` if you need the same behaviour on lazy result sets.
Q02 Will a typed OrderCollection break when I call collect() helpers that return a new instance?
Methods like `filter` and `map` call `$this->newInstance()` internally, which preserves the subclass type. However, `collect()` the global helper always returns a base `Collection`, so avoid wrapping a typed collection in it.
Q03 How do I get IDE autocomplete for macros without a build step?
Add a `/** @method Collection active() */` docblock to a stub file or use `barryvdh/laravel-ide-helper` with `php artisan ide-helper:generate`. For typed subclasses, PHPStan and Psalm pick up the `@extends` generic annotation directly.

Continue reading

More Articles

View all