Extending Laravel Without Forking It
Laravel ships with a Macroable trait that lets you bolt new methods onto core classes at runtime. Used carelessly, this becomes a dumping ground in AppServiceProvider. Used well, it's a clean extension point that keeps your domain vocabulary inside the framework's fluent API.
This article covers three distinct patterns: standalone macros, mixin classes, and purpose-built Collection methods — with concrete examples and the tradeoffs of each.
Macros: One-Off Extensions
The Macroable trait is used by Collection, Builder, Request, Response, Str, Arr, and more. Registering a macro is straightforward:
use Illuminate\Support\Collection;
Collection::macro('toAssoc', function (string $keyField, string $valueField): array {
/** @var Collection $this */
return $this->mapWithKeys(
fn ($item) => [$item[$keyField] => $item[$valueField]]
)->all();
});
// Usage
$map = collect($rows)->toAssoc('id', 'name');
Inside the closure, $this is bound to the macro's host object, so you get full access to its public and protected API. PHPStan and IDE plugins won't know about it by default — address that with a @method annotation on a stub or a dedicated _ide_helper_macros.php file.
Mixins: Grouping Related Macros
When you have five or more related macros, a mixin class keeps them cohesive and testable:
namespace App\Support\Mixins;
class CollectionMixin
{
public function toAssoc(): \Closure
{
return function (string $keyField, string $valueField): array {
return $this->mapWithKeys(
fn ($item) => [$item[$keyField] => $item[$valueField]]
)->all();
};
}
public function groupByFirst(): \Closure
{
return function (string $key): self {
return $this->groupBy(fn ($item) => $item[$key] ?? null);
};
}
}
Register the mixin once, typically in a focused service provider:
use Illuminate\Support\Collection;
use App\Support\Mixins\CollectionMixin;
Collection::mixin(new CollectionMixin());
mixin() reflects over every public method, calls it to retrieve the closure, and registers each as a macro. The result is identical to registering macros individually, but the code is organised and unit-testable in isolation.
Custom Collection Classes: When Macros Aren't Enough
For domain-specific pipelines, a typed custom collection beats macros every time. You get return-type safety, IDE completion, and no global side effects:
namespace App\Domain\Billing\Collections;
use Illuminate\Support\Collection;
use App\Domain\Billing\ValueObjects\Money;
/**
* @extends Collection<int, \App\Domain\Billing\Models\Invoice>
*/
class InvoiceCollection extends Collection
{
public function totalDue(): Money
{
return Money::ofMinorUnits(
$this->sum(fn ($invoice) => $invoice->amount_due_cents)
);
}
public function overdue(): static
{
return $this->filter(
fn ($invoice) => $invoice->due_at->isPast() && ! $invoice->paid
);
}
}
Wire it to your Eloquent model so get() and all() return the typed collection automatically:
class Invoice extends Model
{
public function newCollection(array $models = []): InvoiceCollection
{
return new InvoiceCollection($models);
}
}
Now Invoice::where('user_id', $id)->get() returns an InvoiceCollection, and ->totalDue() is fully typed.
Organising Registration: Avoid the God Provider
Don't pile macros into AppServiceProvider. Instead:
- Create a
MacroServiceProviderper domain or package. - Register it in
bootstrap/providers.php(Laravel 11+) orconfig/app.php. - Keep each mixin class in
app/Support/Mixins/or inside the relevant domain folder.
// app/Providers/BillingMacroServiceProvider.php
public function boot(): void
{
Collection::mixin(new CollectionMixin());
Request::mixin(new RequestMixin());
}
This makes it trivial to extract a domain into a package later — the service provider and mixin classes move together.
Key Takeaways
- Use standalone macros for one-off, cross-cutting helpers.
- Use mixin classes when you have a cohesive group of related extensions; they're unit-testable and self-documenting.
- Use custom Collection subclasses for domain-specific pipelines where return-type safety matters.
- Register macros in dedicated service providers, not
AppServiceProvider. - Generate IDE helper stubs (
php artisan ide-helper:generate) or maintain@methodannotations so static analysis tools stay accurate.