Macros in Laravel

laravel
Table of Contents

Laravel's Illuminate\Support\Macroable trait allows you extend classes at runtime with custom methods. The idea is that you provide a named Closure, or callable value, which can then be invoked using PHP's __call() and __callStatic() magic methods.

Let's take a look at an example using a Collection.

$collection = collect([
    'foo' => [
        'bar' => [
            'baz' => 'bob',
        ],
    ],
]);

$collection->get('foo.bar.baz')

I want this code to take the dot-notated path and return the value. The Collection object doesn't support this though. One solution would be swapping out the Collection::get() call for a data_get() call.

$baz = data_get($collection, 'foo.bar.baz');

That does actually work since the data_get() function has some special logic for Collection objects. From a readability point of view, it's not quite as nice though. The Collection object is known for its fluency and method chaining and this bit of code doesn't fit into that pattern.

Let's add a macro the Collection object that does this logic for us.

use Illuminate\Support\Collection;

Collection::macro('path', function (string $path) {
    return data_get($this, $path);
});

The Macroable::macro() method accepts 2 arguments. The first is the name of the macro and the second is a Closure or callable value.

We can now call a magic path() method on the $collection and it will return the value found at the path provided.

$baz = collect([
    // ...
])
    ->path('foo.bar.baz');

Behind the scenes the method call is being delegated to the __call() method implemented by the Macroable trait. It will check if the macro exists and invoke the callback if it does.

What is special about the callback is that we can use $this and reference the object we're calling the macro on instead of the $this context where the macro is defined.

Inside of the Closure, $this is actually the Collection object that we created. To help our IDE understand the context a little more, we can add a DocBlock at the top of the Closure.

Collection::macro('path', function (string $path) {
    /** @var \Illuminate\Support\Collection $this */
    return data_get($this, $path);
});

Since macros are defined at runtime, your IDE likely won't be able to provide any autocomplete or intellisense. I've found the best way to fix this problem is by creating a "stubs" file that has a carcass definition of your macros.

This file has multiple purposes:

  1. It lets your IDE and static analysis tools discover the macros your define.
  2. It serves as a single-source of truth for the macros that you define in your project.

I typically create a .stubs.php file in the root of my project. The path() macro might be stubbed out like below.

<?php

namespace Illuminate\Support
{
    class Collection
    {
        public function path(string $path): mixed {}
    }
}

If your tooling is able to scan that file, it should add the Collection::path() definition to its indexes and provide some autocomplete for you.

Enjoyed this post or found it useful? Please consider sharing it on Twitter.