Laravel Macros, Mixins, and Custom Collection Methods That Actually Ship
The Macroable trait is one of Laravel's quieter superpowers. Used well, it lets you extend core classes without forking the framework. Used carelessly, it produces invisible globals that confuse teammates and break under Octane. This article covers the practical patterns that survive production.
How Macroable Works Under the Hood
Any class that uses Illuminate\Support\Traits\Macroable stores closures in a static $macros array. When you call an unknown method, __call (or __callStatic) checks that array and invokes the closure, binding $this to the current instance.
// Simplified internals
public static function macro(string $name, callable $macro): void
{
static::$macros[$name] = $macro;
}
public function __call(string $method, array $parameters)
{
if (isset(static::$macros[$method])) {
return Closure::bind(static::$macros[$method], $this, static::class)(...$parameters);
}
throw new BadMethodCallException(...);
}
Because $macros is static, macros registered in a service provider persist for the lifetime of the PHP process — which is exactly what you want in a traditional FPM setup, and exactly what you must reason about under Octane.
Registering Macros in a Service Provider
Always register in boot(), never in register(). The framework classes you're extending may not be bound yet during register().
namespace App\Providers;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
class CollectionMacroServiceProvider extends ServiceProvider
{
public function boot(): void
{
Collection::macro('filterMap', function (callable $callback): Collection {
/** @var Collection $this */
return $this->map($callback)->filter()->values();
});
Collection::macro('groupByFirst', function (string $key): Collection {
/** @var Collection $this */
return $this->keyBy(fn ($item) => data_get($item, $key));
});
}
}
Register the provider in bootstrap/providers.php (Laravel 11+) or config/app.php.
Mixins: Sharing Many Methods at Once
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 must return a Closure.
namespace App\Support\Mixins;
use Illuminate\Support\Collection;
class CollectionMixin
{
public function toAssoc(): \Closure
{
return function (string $keyField, string $valueField): Collection {
/** @var Collection $this */
return $this->mapWithKeys(
fn ($item) => [data_get($item, $keyField) => data_get($item, $valueField)]
);
};
}
public function chunkWhile(): \Closure
{
// Laravel already has chunkWhile; this is illustrative
return function (callable $callback): Collection {
/** @var Collection $this */
$chunks = [];
$chunk = [];
foreach ($this->items as $item) {
if (empty($chunk) || $callback($item, end($chunk))) {
$chunk[] = $item;
} else {
$chunks[] = $chunk;
$chunk = [$item];
}
}
if ($chunk) {
$chunks[] = $chunk;
}
return new static($chunks);
};
}
}
Register the mixin in one call:
Collection::mixin(new CollectionMixin());
Octane Safety: Static State Is Shared Across Requests
Because macros live in static arrays, they are registered once when the worker boots and remain for every subsequent request. This is fine — registration is idempotent. The danger is registering macros inside a request cycle (e.g., in a controller or middleware), which can cause subtle state leakage if the closure captures request-scoped objects.
Rule: register macros only in service providers, never inside request-handling code.
IDE Support with @mixin and Laravel IDE Helper
Without hints, PhpStorm treats macro calls as errors. Two approaches:
- Add a
/** @mixin CollectionMixin */docblock to a stub file thatide-helperpicks up. - Use
barryvdh/laravel-ide-helperwithphp artisan ide-helper:generate— it reads registered macros and writes stubs automatically.
Extending Beyond Collection
The same pattern works on Request, Builder (query builder), Carbon, and Response:
use Illuminate\Http\Request;
Request::macro('isHtmx', function (): bool {
/** @var Request $this */
return $this->hasHeader('HX-Request');
});
// Usage in a controller:
if ($request->isHtmx()) {
return view('partials.table');
}
Extending Illuminate\Database\Query\Builder follows the same pattern but be careful: the query builder is instantiated per query, so closures must not assume singleton state.
Takeaways
- Register macros in
boot()inside a dedicated service provider — never inside request handlers. - Use mixins when you have more than two or three related methods; it keeps the provider clean.
- Closures in macros bind
$thisto the host instance, so you get full access to internal properties. - Under Octane, static macro registration is safe because it happens once at worker boot.
- Add IDE stubs via
ide-helperor@mixindocblocks so teammates get autocomplete. - Extending
Requestwith domain-specific helpers (e.g.,isHtmx(),tenantId()) is one of the highest-value uses of macros in a real application.