Laravel’s mysteriously macroable paginators

Laravel’s Macroable trait is a very neat way to add new functionality to built-in classes.

I recently found myself with the need to add a custom method to the LengthAwarePaginator class. Not a problem, I thought, I’ll write a quick macro.

A fine solution, but for the fact the LengthAwarePaginator isn’t “macroable”. Or so it would seem at first glance.

How to fix a problem that doesn’t exist

Upon discovering that the LengthAwarePaginator doesn’t use the Macroable trait, my first thought was that I should add this functionality, and submit a pull request. It’s trivially easy to do, and requests to make classes macroable are generally met with fairly swift approval.

Being the good little developer that I am, I started by writing a test:

<?php

namespace Illuminate\Tests\Pagination;

use PHPUnit\Framework\TestCase;
use Illuminate\Pagination\LengthAwarePaginator;

class LengthAwarePaginatorTest extends TestCase
{
    public function setUp()
    {
        $this->p = new LengthAwarePaginator($array = ['item1', 'item2', 'item3', 'item4'], 4, 2, 2);
    }
    
    public function tearDown()
    {
        unset($this->p);
    }
    
    // Existing tests...
    
    public function testLengthAwarePaginatorIsMacroable()
    {
        $this->p->macro('foo', function () {
            return 'bar';
        });
        
        $this->assertEquals('bar', $this->p->foo());
    }
}

Then I ran my test, watched it fail, and… it passed. Something was clearly afoot.

After confirming that neither the LengthAwarePaginator, nor the AbstractPaginator use the Macroable trait, I started digging.

Confirming the impossible

The following code confirms that neither the LengthAwarePaginator, nor any other class in its inheritance tree, uses the Macroable trait:

<?php

require_once(__DIR__ . '/vendor/autoload.php');

use Illuminate\Pagination\LengthAwarePaginator;

$reflector = new ReflectionClass(LengthAwarePaginator::class);

dd(getTraits($reflector));    // Outputs []

function getTraits(ReflectionClass $reflector, array $traits = []): array
{
    if ($reflector->getParentClass()) {
        $traits = getTraits($reflector->getParentClass(), $traits);
    }

    return array_merge($traits, $reflector->getTraitNames());
}

And yet, somehow, the LengthAwarePaginator has all of the methods inherited from the Macroable trait.

<?php

require_once(__DIR__ . '/vendor/autoload.php');

use Illuminate\Pagination\LengthAwarePaginator;

$paginator = new LengthAwarePaginator(['a', 'b', 'c', 'd'], 4, 2, 2);

$paginator->macro('foo', function () {
    return 'foo';
});

$paginator->hasMacro('foo');    // Outputs true
$paginator->hasMacro('bar');    // Outputs false
$paginator->foo();              // Outputs 'foo'

The magic macro method

Further code spelunking finally led me to the __call magic method on the AbstractPaginator class:

/**
 * Make dynamic calls into the collection.
 *
 * @param  string  $method
 * @param  array  $parameters
 * @return mixed
 */
public function __call($method, $parameters)
{
    return $this->getCollection()->$method(...$parameters);
}

/**
 * Get the paginator's underlying collection.
 *
 * @return \Illuminate\Support\Collection
 */
public function getCollection()
{
    return $this->items;
}

As you can see, AbstractPaginator::__call passes any requests for unknown methods to the paginator’s $items property. $items is an instance of the Illuminate\Support\Collection class, which is “macroable.”

So, behind the scenes, this is what’s happening:

  1. We call the macro method on the LengthAwarePaginator
  2. The macro method doesn’t exist on the LengthAwarePaginator class, and neither does the __call magic method, so PHP checks the parent AbstractPaginator class
  3. The macro method doesn’t exist on the AbstractPaginator class either, but the __call method does
  4. AbstactPaginator::__call attempts to call the macro method on the $items property, which is an instance of Illuminate\Support\Collection
  5. The macro method doesn’t exist directly on the Illuminate\Support\Collection class, but it does exist on the Macroable trait, which the Collection class uses
  6. Finally, at long last, the Macroable::macro method runs

I’m accustomed to encountering TaylorMagic™ when working with Laravel, but I can’t imagine this was intentional. It’s far too obtuse.

That said, it does work perfectly, so if you ever wished the LengthAwarePaginator was macroable, I have good news; it is.

Sign up for my newsletter

A monthly round-up of blog posts, projects, and internet oddments.