Extending Laravel Without Forking It
Laravel ships with a Macroable trait that lets you bolt new behaviour onto core classes at runtime. Used well, it keeps domain language inside your codebase rather than scattered across helper files. Used carelessly, it turns a clean project into a maze of invisible methods. This article covers the mechanics, the guardrails, and the patterns that actually hold up at scale.
How Macroable Works
Any class that uses Illuminate\Support\Traits\Macroable gains two static methods: macro() and mixin(). At call time, __call and __callStatic proxy to the registered closure.
use Illuminate\Support\Collection;
Collection::macro('toAssoc', function (string $key, string $value): Collection {
/** @var Collection $this */
return $this->mapWithKeys(fn ($item) => [$item[$key] => $item[$value]]);
});
$result = collect([
['code' => 'USD', 'label' => 'US Dollar'],
['code' => 'EUR', 'label' => 'Euro'],
])->toAssoc('code', 'label');
// ['USD' => 'US Dollar', 'EUR' => 'Euro']
Inside the closure, $this is bound to the current collection instance, so you have full access to $this->items and every existing method.
Registering Macros the Right Way
Never register macros in a controller or a model. Always use a service provider so the macro is available on every request before any consumer needs it.
// app/Providers/CollectionMacroServiceProvider.php
namespace App\Providers;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
class CollectionMacroServiceProvider extends ServiceProvider
{
public function boot(): void
{
Collection::macro('second', function (): mixed {
return $this->skip(1)->first();
});
Collection::macro('mapToModel', function (string $class): Collection {
return $this->map(fn ($data) => new $class($data));
});
}
}
Add it to bootstrap/providers.php (Laravel 11+) or config/app.php providers array.
Mixins: Grouping Related Macros
When you have a family of related methods, a mixin class is cleaner than a long list of macro() calls. Each public method on the mixin class returns a closure that becomes a macro.
// app/Mixins/CollectionCurrencyMixin.php
namespace App\Mixins;
use Illuminate\Support\Collection;
class CollectionCurrencyMixin
{
public function sumMoney(): \Closure
{
return function (string $field, int $scale = 2): string {
/** @var Collection $this */
$total = $this->sum(fn ($item) => (int) round($item[$field] * (10 ** $scale)));
return number_format($total / (10 ** $scale), $scale);
};
}
public function formatAmounts(): \Closure
{
return function (string $field, string $currency = 'USD'): Collection {
/** @var Collection $this */
return $this->map(function ($item) use ($field, $currency) {
$item[$field . '_formatted'] = $currency . ' ' . number_format($item[$field], 2);
return $item;
});
};
}
}
// In your service provider boot()
Collection::mixin(new CollectionCurrencyMixin());
IDE Support: Don't Skip This
Macros are invisible to static analysis. Add a @mixin docblock or generate an _ide_helper_macros.php file with barryvdh/laravel-ide-helper. For Psalm/PHPStan, create a stub:
// stubs/CollectionMacros.php (excluded from autoload)
/** @mixin \Illuminate\Support\Collection */
class CollectionMacroStub
{
public function toAssoc(string $key, string $value): \Illuminate\Support\Collection {}
public function second(): mixed {}
}
Point your phpstan.neon at the stubs directory. Now your CI pipeline catches misuse.
Beyond Collections: Other Macroable Classes
Request, Response, Builder (query builder), Router, Str, Arr, and Carbon (via CarbonMixin) all support macros. A practical example — adding a typed helper to the query builder:
use Illuminate\Database\Query\Builder;
Builder::macro('whereUuid', function (string $column, string $uuid): Builder {
/** @var Builder $this */
return $this->where($column, '=', $uuid);
});
// Usage
User::query()->whereUuid('id', $request->uuid)->firstOrFail();
When Not to Use Macros
- Complex logic with dependencies — inject a service instead; closures can't receive constructor injection cleanly.
- Methods that need to be overridden per model — use a custom base model or a trait.
- Anything that should be tested in isolation — macros are global state; prefer explicit classes for domain-critical logic.
Takeaways
- Register all macros in a dedicated service provider, never inline.
- Use mixin classes to group related macros and keep providers readable.
- Add IDE stubs or
@mixinannotations — macros without static analysis support create invisible debt. Macroableis available onRequest,Builder,Str,Router, and more, not justCollection.- Macros are global state; reserve them for genuinely cross-cutting, stateless helpers.