Tag Archives: laravel-routing

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.

Multiple Subdomain and TLD Routing in Laravel 5

A recent project I’ve been working on has a requirement to have two concurrent versions of an application running on different subdomains.

It looks something like:

  • sandbox.example.com – A sandbox version of the application
  • live.example.com – The live version

The local developer version has the same URLs, just with a .test or .local TLD.

Laravel 5 supports subdomain routing, but not really for this use case, and honestly, not all that well out of the box.

This is the example found in the documentation:

Route::domain('{account}.myapp.com')->group(function () {
    Route::get('user/{id}', function ($account, $id) {
        //
    });
});

Problem 1: This route is now hardcoded to the .com TLD.

You can’t develop locally any more because your local environment will be probably on a .test TLD or another one of the reserved testing TLDs.

You might think that Laravel could intelligently swap out the TLD when it’s being ran in development mode, but no such feature exists. The code example from the docs cannot be copy and pasted into a real project and work as expected.

That said, one possible work around for this is to put the TLD in an environment variable that can be swapped out at run time.

Route::domain('{account}.myapp.' . config('app.tld'))->group(function () {
    Route::get('user/{id}', function ($account, $id) {
        //
    });
});

Problem 2: This route now requires the {account} parameter.

Let’s name this route and try to use generate a link with the route helper.

Route::domain('{account}.myapp.' . config('app.tld'))->group(function () {
    Route::get('user/{id}', function ($account, $id) {
        //
    })->name('account.profile');
});
// Illuminate \ Routing \ Exceptions \ UrlGenerationException
// Missing required parameters for [Route: account.profile] [URI: user/{id}].
return route('account.profile', ['id' => 1234]);

The subdomain parameter is required but not provided, so UrlGenerationException is thrown. Also note that Laravel doesn’t tell you which parameter is missing which is not very helpful!

Obviously you can fix this by providing the subdomain in every call to route, but this has obvious problems:

  • All controller methods will need to accept the subdomain as a parameter and pass it along to the view.
  • All calls to the route helper will need to specify the subdomain

This perhaps makes sense if you are basing application logic on different subdomains, but in my case I’m not.

The solution

In your EventsServiceProvider, listen for the RouteMatched Event. When it fires, Laravel will have matched the route to use and will populate the current route with the parameters from the request.

You can inject these into the UrlGenerator‘s default values.

From this point on, the parameters you specified in the domain of your route will be filled with whatever matched them.

//EventsServiceProvider.php
protected $listen = [
   RouteMatched::class => [
       SetUrlDefaults::class,
   ],
];
//SetUrlDefaults.php
use Illuminate\Routing\Router;
use Illuminate\Routing\UrlGenerator;

class SetUrlDefaults
{
    private $urlGenerator;
    private $router;

    public function __construct(UrlGenerator $urlGenerator, Router $router)
    {
        $this->urlGenerator = $urlGenerator;
        $this->router       = $router;
    }

    public function handle(): void
    {
        $this->urlGenerator->defaults($this->router->current()->parameters);
    }
}

You could also use the URL and Router facades here if you really want. I prefer dependency injection.

Conclusion

Maybe all this seems obvious in hindsight, but figuring this out took me several hours and many missteps in the wrong direction.

It’s disappointing to me that examples in the official documentation have implementation issues that require either a good amount of experience of the framework or more advanced code splunking abilities.  I would put my solution and debugging above what I would expect from a junior or even mid tier developer.