Laravel Macros, Mixins, and Custom Collection Methods That Actually Ship
#laravel #macros #collections #php

Laravel Macros, Mixins, and Custom Collection Methods That Actually Ship

4 min read Mohamed Said Mohamed Said

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:

  1. Add a /** @mixin CollectionMixin */ docblock to a stub file that ide-helper picks up.
  2. Use barryvdh/laravel-ide-helper with php 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 $this to 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-helper or @mixin docblocks so teammates get autocomplete.
  • Extending Request with domain-specific helpers (e.g., isHtmx(), tenantId()) is one of the highest-value uses of macros in a real application.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I override an existing Collection method with a macro?
No. `__call` is only invoked for methods that do not exist natively on the class. If a method is already defined, the macro is silently ignored. Use a subclass or a decorator if you need to override native behaviour.
Q02 Is it safe to type-hint macro return values in strict PHP 8.3 codebases?
Macros are resolved at runtime, so PHP's static analyser cannot infer their return types. Annotate the closure's return type explicitly and add a `@method` docblock to a stub class so PHPStan or Psalm can follow the type through call sites.
Q03 Should every team utility live as a macro, or is there a better place?
Prefer macros for methods that genuinely feel native to the extended class (e.g., a Collection helper that reads like a built-in). For domain logic, a dedicated service or action class is clearer and easier to test in isolation.

Continue reading

More Articles

View all