Route to madness: Too much magic in Laravel!

Some people say that Laravel’s facades are optional, and if you don’t want to use them, then you can use dependency injection or the helper methods instead.

Laravel does have a powerful DI container. I have tried to go as facade-less as I can, but again I’ve found routing to be a pain.

Using some of PHP’s magic methods, Laravel likes to proxy calls. A lot. The obvious examples are, of course, Facades. When you call something like Cache::get('something'), Laravel proxies that call into the container and whatever service is required. There’s even an explanation in the official documentation.

One of the problems with proxying calls like this is that it can become a little too magical. Out of the box, a call like this cannot be analysed statically (that is to say, without running the code), to determine what’s going on.

The solution to this is to provide hints for an IDE to pick up on. There’s a package for this which outputs an _ide_helper.php file containing hundreds of automatically generated hints.

Recently, I wanted to see if it was possible to ditch facades in the routes file. The method that loads routes adds a $router variable when it includes the routes file, so it seems like Laravel is indicating you can:

//Illuminate\Routing\Router
protected function loadRoutes($routes)
{
    if ($routes instanceof Closure) {
        $routes($this);
    } else {
        $router = $this;

        require $routes;
    }
}

And a cursory test proves this to work.

// Works
Route::get('/', function () {
    return 'Hello World';
});

// Also works
$router->get('/', function () {
    return 'Hello World';
});

Other method calls also work:

Route::view('/', 'welcome');

$router->view('/', 'Welcome');

We can also use closures for things like groups:

Route::middleware([TrimStrings::class])->group(function () {
    Route::get('/', function () {
        return '/';
    });
});

$router->middleware([TrimStrings::class])->group(function () use ($router) {
    $router->get('/', function () {
        return '/';
    });
});

The only difference to the code is that we have to pass the $router variable into the closure because it’s otherwise not in scope.

tl;dr – replace Route:: with $router-> and remember to pass the $router variable into closures. Simple. End of blog post right? No.

Consider Route Prefixes, the example in the documentation looks like this:

Route::prefix('admin')->group(function () {
    Route::get('users', function () {
        return '/admin/users';
    });
});

Following the previous facade removals, we should be able to change this to:

$router->prefix('admin')->group(function () use ($router) {
    $router->get('users', function () {
        return '/admin/users';
    });
});

But when we try this, the first example works, but the Facade-less version breaks:

Wtf why?

No, this isn’t a typo or other trivial bug in my code. It’s a deeper issue with how Laravel proxies calls all over the place.

Let’s first look at the Facade version and follow it.

The Route facade extends an abstract facade class which implements __callStatic, passing in the method name ('proxy') and the arguments passed in (the closure):

public static function __callStatic($method, $args)
{
    $instance = static::getFacadeRoot();

    if (! $instance) {
        throw new RuntimeException('A facade root has not been set.');
    }

    return $instance->$method(...$args);
}

The call to getFacadeRoot essentially just returns $app['route'] – or whatever key the facade says to use.

So when we call Route::prefix, Laravel resolves the Router from its container, then invokes the prefix method on it.

If we look at the Router class, does it have a public prefix method? No. Public is important, because if the method exists but it’s protected or private, then it cannot be called and instead PHP try __call instead.

We can demonstrate this easily:

$test = new class
{
    protected function foo(): void
    {
        print 'foo';
    }

    public function __call($name, $arguments)
    {
        print 'calling magic method!';
    }
};

$test->foo();
$ php test.php
calling magic method!

Instead of calling the foo method which does exist, PHP calls __call because the foo method is not public.

The Router does have a protected prefix method. So why is it called when using $router but not when using a facade?

Answer: Because $router is actually an alias of $this.

Cast your memory back to when the routes file is included:

//Illuminate\Routing\Router
protected function loadRoutes($routes)
{
    if ($routes instanceof Closure) {
        $routes($this);
    } else {
        $router = $this;

        require $routes;
    }
}

Which means the in the facade-less code, you can replace $router with $this and it’s functionally the same:

$router->prefix('admin')->group(function () use ($router) {
    $router->get('users', function () {
        return '/admin/users';
    });
});

$this->prefix('admin')->group(function () {
    $this->get('users', function () {
        // Matches The "/admin/users" URL
        return '/admin/users';
    });
});

Of course $this can call its own protected method, so it does, and breaks, because while the facade version will correctly call Router::__call, and then filter its way into RouteRegistrar, the facade-less version will instead call Route::prefix and break.

What a mess.

The workaround to this, which I eventually ditched in favour of returning to facades, is to explicitly call __call and wrap the arguments with an array

Which means these three approaches are all equivalent:

Route::prefix('admin')->group(function () {
    Route::get('users', function () {
        return '/admin/users';
    });
});

$router->__call('prefix', ['admin'])->group(function () use ($router) {
    $router->get('users', function () {
        return '/admin/users';
    });
});

$this->__call('prefix', ['admin'])->group(function () {
    $this->get('users', function () {
        return '/admin/users';
    });
});

In the end, I ended up going back to using facades for routing.

Leave a Reply

Your e-mail address will not be published. Required fields are marked *