Why Macros Deserve More Respect
Most teams discover Macro when they want a one-liner on Str or Collection. They drop it in AppServiceProvider::boot, ship it, and move on. That works — until you run under Octane, add PHPStan, or onboard a developer who has no idea where ->toAssocBy() came from.
This article covers the three patterns that actually hold up in production: targeted macros, mixins, and typed Collection subclasses.
Pattern 1 — Targeted Macros in Focused Providers
Registering every macro in AppServiceProvider creates a god-provider. Instead, create a dedicated provider per domain.
// app/Providers/CollectionMacroServiceProvider.php
class CollectionMacroServiceProvider extends ServiceProvider
{
public function boot(): void
{
Collection::macro('toAssocBy', function (string $key): Collection {
/** @var Collection $this */
return $this->keyBy($key);
});
Collection::macro('filterMap', function (Closure $callback): Collection {
/** @var Collection $this */
return $this->map($callback)->filter()->values();
});
}
}
Register it in bootstrap/providers.php (Laravel 11+) or config/app.php. The provider is small, testable, and easy to grep.
Octane Safety
Macros are stored in a static $macros array on the Macroable trait. Under Octane the worker boots once, so macros registered in boot() persist across requests — that is exactly what you want. The danger is re-registering inside a request lifecycle (e.g., inside a Livewire component). Keep macro registration in providers only.
Pattern 2 — Mixins for Cohesive Method Groups
A mixin is a plain class whose public methods become macros in bulk. Use it when you have five or more related methods.
// app/Support/CollectionDateMixin.php
/**
* @mixin \Illuminate\Support\Collection
*/
class CollectionDateMixin
{
public function betweenDates(): Closure
{
return function (string $field, Carbon $from, Carbon $to): Collection {
/** @var Collection $this */
return $this->filter(
fn ($item) => data_get($item, $field) >= $from
&& data_get($item, $field) <= $to
)->values();
};
}
public function latestBy(): Closure
{
return function (string $field): mixed {
/** @var Collection $this */
return $this->sortByDesc($field)->first();
};
}
}
// In your provider
Collection::mixin(new CollectionDateMixin());
The @mixin docblock is picked up by Laravel IDE Helper and PHPStan (with the larastan/larastan extension), giving you autocompletion and type inference without extra stubs.
Pattern 3 — Typed Collection Subclasses (The Underused One)
For domain-specific collections, a subclass beats a macro every time. You get real return types, no @var hacks, and Eloquent integrates natively.
// app/Domain/Billing/InvoiceCollection.php
/**
* @extends Collection<int, Invoice>
*/
class InvoiceCollection extends Collection
{
public function totalOwed(): Money
{
return $this->reduce(
fn (Money $carry, Invoice $invoice) => $carry->add($invoice->amount),
Money::of(0, 'GBP')
);
}
public function overdue(): static
{
return $this->filter(
fn (Invoice $i) => $i->due_at->isPast() && ! $i->paid
)->values();
}
}
Tell Eloquent to use it:
// app/Models/Invoice.php
class Invoice extends Model
{
public function newCollection(array $models = []): InvoiceCollection
{
return new InvoiceCollection($models);
}
}
Now Invoice::all() returns InvoiceCollection, and PHPStan knows it.
Testing Your Macros
// tests/Unit/CollectionMacroTest.php
it('filterMap removes null results', function () {
$result = collect([1, 2, 3, 4])
->filterMap(fn ($n) => $n % 2 === 0 ? $n * 10 : null);
expect($result->values()->all())->toBe([20, 40]);
});
Unit-test macros in isolation — no HTTP, no database. Fast feedback, easy CI.
Takeaways
- Register macros in dedicated providers, never inside request-scoped code.
- Use mixins when you have a cohesive group of five or more methods; the
@mixindocblock unlocks IDE and static analysis support. - Prefer typed Collection subclasses for domain models — they give real generics, no magic strings, and Eloquent wires them up automatically.
- Macros survive Octane restarts because they live in static state bootstrapped once per worker.
- Always write a unit test per macro; they are trivial to test and painful to debug silently broken.