0

Laravel's macro system — Macroable trait

Advanced5 min read·lv-01-008
laravel-srcinterview

Concept

The Macroable trait (Illuminate\Support\Traits\Macroable) is one of Laravel's most elegant extension mechanisms. It allows any class that uses it to have new methods added at runtime, without modifying the class source and without subclassing. This is how Laravel enables package authors and application developers to extend framework classes like Collection, Request, Response, Builder, and Router without monkey-patching.

The implementation is surprisingly simple. Macroable maintains a static $macros array. The macro(string $name, callable $macro) static method stores a callable under the given name. __call($method, $parameters) and __callStatic($method, $parameters) magic methods check this array and dispatch to the stored callable if found. The magic is in how $this is bound: Closure::bind($macro, $this, static::class) ensures that when the macro closure is called, $this refers to the calling object instance and static refers to the correct class. This means a macro can access the object's private and protected properties — it runs as if it were a method defined directly on the class.

mixin(object $mixin) is the batch version of macro(). Pass it an object, and every public method on that object (excluding inherited ones) becomes a macro. This is how you can define a mixin class containing multiple related macros and register them all in one call.

hasMacro(string $name) lets you check if a macro exists before calling it — useful for packages that conditionally add macros only if another package hasn't already defined them.

The typical place to register macros is in a service provider's boot() method. Registering in register() is also possible but unusual, since macros typically depend on other services being available.

One important limitation: IDEs and static analysis tools don't know about macros. A @mixin PHPDoc annotation on the class tells IDEs about macros, but tools like PHPStan/Psalm require explicit annotations or stubs. Many packages ship ide-helper.php files that contain doc-blocks declaring the macros they add.

Code Example

php
<?php
// Registering macros in a service provider

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Collection;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\Builder;

class MacroServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // 1. Collection macro — adds a groupByFirst() helper
        Collection::macro('groupByFirst', function (string $key) {
            // $this is the Collection instance because Closure::bind() is used
            return $this->groupBy($key)->map->first();
        });

        // 2. Request macro — detect if request wants JSON or is from a mobile app
        Request::macro('wantsJson', function () {
            // This macro already exists — example of checking first
        });

        Request::macro('isMobileApp', function () {
            return str_starts_with($this->header('X-App-Version', ''), 'mobile-');
        });

        // 3. Eloquent Builder macro — adds an active() scope universally
        Builder::macro('active', function () {
            return $this->where('is_active', true);
        });

        // 4. Using a mixin class for grouped macros
        Collection::mixin(new \App\Support\CollectionMixins);
    }
}
php
<?php
// A mixin class — groups related macros
namespace App\Support;

class CollectionMixins
{
    // Each public method becomes a macro
    public function sumByKey(): \Closure
    {
        return function (string $key): int|float {
            return $this->sum($key); // $this = Collection instance
        };
    }

    public function toAssoc(): \Closure
    {
        return function (string $keyBy, string $valueColumn): array {
            return $this->pluck($valueColumn, $keyBy)->all();
        };
    }

    public function whereNotEmpty(): \Closure
    {
        return function (string $key): static {
            return $this->filter(fn($item) => !empty($item[$key]));
        };
    }
}

// Usage after registration:
$totals = collect($orders)->groupByFirst('status');
$map = collect($users)->toAssoc('id', 'name');
$activeUsers = collect($users)->whereNotEmpty('email');
php
<?php
// The Macroable trait internals — simplified
trait Macroable
{
    protected static array $macros = [];

    public static function macro(string $name, callable $macro): void
    {
        static::$macros[$name] = $macro;
    }

    public static function mixin(object $mixin, bool $replace = true): void
    {
        $methods = (new \ReflectionClass($mixin))->getMethods(\ReflectionMethod::IS_PUBLIC);
        foreach ($methods as $method) {
            if ($replace || !static::hasMacro($method->name)) {
                $method->setAccessible(true);
                static::macro($method->name, $method->invoke($mixin));
            }
        }
    }

    public static function hasMacro(string $name): bool
    {
        return isset(static::$macros[$name]);
    }

    public function __call(string $method, array $parameters): mixed
    {
        if (!static::hasMacro($method)) {
            throw new \BadMethodCallException("Method {$method} does not exist.");
        }
        $macro = static::$macros[$method];
        if ($macro instanceof \Closure) {
            // Bind $this to the current instance — macros can access private members
            $macro = \Closure::bind($macro, $this, static::class);
        }
        return $macro(...$parameters);
    }
}

Interview Q&A

Q: How does the Macroable trait allow macros to access private and protected members of the target class?

The __call() implementation in Macroable uses Closure::bind($macro, $this, static::class). The second argument binds $this inside the closure to the current object instance. The third argument sets the closure's scope class to static::class — the class that uses the trait. In PHP, Closure::bind with a scope class grants the closure the same access rights as a method defined on that class, including access to protected and private properties and methods. This is why a Collection macro can call $this->items (the protected backing array) directly.


Q: What is the difference between macro() and mixin() on the Macroable trait?

macro() registers a single named callable. mixin() uses Reflection to inspect an object's public methods, calls each one (which should return a Closure), and registers each Closure as a macro under the method's name. The pattern is: define a mixin class where each method returns a Closure containing the actual macro logic. This lets you organize related macros in a class, type-hint them, and test them independently. The $replace parameter (default true) controls whether an existing macro with the same name gets overwritten — setting it to false allows packages to register macros safely without overwriting user-defined ones.


Q: Where in the Laravel request lifecycle should macros be registered, and what happens if they're registered too early?

Macros should be registered in a service provider's boot() method. Registering in register() usually works but is technically wrong — if your macro closure resolves services from the container (e.g., app()->make(SomeService::class)), those services may not be registered yet when register() runs. In boot(), all providers have registered, so every binding is available. Registering too early is unlikely to cause issues for simple macros that don't resolve services, but it's an ordering violation that can cause subtle failures. Registering too late (e.g., inside a middleware that runs per-request) re-registers on every request, which wastes memory since the $macros array is static and persists across requests in long-running processes like Octane.