Eric Koyanagi
Just Another Tech Blog
« Back to Article List

Laravel Deep Dives: The Router

By Eric Koyanagi
Posted on 08/20/23

How the Laravel Router Really Works

This article isn't going to discuss how to use Laravel's router system. Instead, I want to look into the granular details of how Laravel's routing system works. In this and future articles, I will be looking into the framework code to see the lower-level details of how these concepts actually work.

Why? Because it's worth understanding how low-level details are handled! This might improve the context for how I understand higher-level resources or methods, and can teach me new things about the platform.

Routers are good "entry points" into an MVC application conceptually. As such, it's a logical place to start if you're simply reading through Laravel's source code trying to understand the framework at its most fundamental.

Building a router isn't very hard. In making simple frameworks myself, this is among the more fun and powerful pieces. Most frameworks have routers that work in a similar way. The basic steps might be as follows:

  1. Add a rewrite rule to the application's .htaccess file that forces all requests to resolve to the same backend script (index.php usually), passing in the rest of the URL as a parameter
  2. Parse the URL in index.php, bootstrapping and auto-loading any required files
  3. If the parsed URL matches a class (controller), defer the rest of the logic to that...otherwise, throw a 404

I've worked with Laravel's router plenty of times...but have I dissected every piece? Let's do that and compare it to the "naive" implementation of a basic router outlined above.

Looking at index.php

Simply opening "public/index.php" reveals Laravel's application "entry point". They are kind enough to outline a few basic steps in comments:

  1. Check if the site is under maintenance
  2. Register the autoloader
  3. Run the application

Of course Laravel needs to initialize the autoloaders before it can parse and "dispatch" the route, but otherwise there's not much meat to this file.

$app = require_once __DIR__.'/../bootstrap/app.php';

$kernel = $app->make(Kernel::class);

$response = $kernel->handle(
    $request = Request::capture()
)->send();

$kernel->terminate($request, $response);

Let's look into this piece in more detail to understand exactly how Laravel captures the request and what it does with it next.

Capturing the Request

We see that the $request object is created here, where we can assume it's passed to matching controllers or routes later on. Let's ignore the bootstrapping boilerplate and focus on the capturing of the $request for now.

return static::createFromBase(SymfonyRequest::createFromGlobals());

This line in Http/Request.php reveals how the ubiquitous $request class in in fact a Symfony component. Laravel uses various Symfony components, so this isn't a surprise. Specifically, the HttpFoundation component.

In "createFromGlobals", we can see that Symfony follows a factory pattern to create the request object. You can follow the source code here if you aren't following along in a Laravel project...which you probably aren't.

return new static($query, $request, $attributes, $cookies, $files, $server, $content);

Unless we supply a custom factory, this line will execute. "New static" is something that not every PHP developer sees often. In this case, it means "return an instance of whatever class in the hierarchy this was called on". In other words, if this method were invoked in a child class of $request, it would create an instance of that child class.

Okay, so we can see how Symfony creates a $request class...but what now? How does that request actually get routed to a controller?

The Illuminate Kernel Router

Remember this portion of our index.php entry point?

$response = $kernel->handle(
    $request = Request::capture()
)->send()

Let's dig into this in more detail. The Kernel's "handle" method is the first place to look.

public function handle($request)
    {
        $this->requestStartedAt = Carbon::now();
        try {
            $request->enableHttpMethodParameterOverride();
            $response = $this->sendRequestThroughRouter($request);
        } catch (Throwable $e) {
            $this->reportException($e);
            $response = $this->renderException($request, $e);
        }

        $this->app['events']->dispatch(
            new RequestHandled($request, $response)
        );

        return $response;
    }

Here we timestamp the request start time via Carbon...then capture the $response by dispatching the request through the router. Perfect! Here we also see a high level error handling try/catch and event handling. In "sendRequestThroughRouter", we start to see more familiar logical details related to routing reveal themselves.

It's also worth noting that here is where the request start is timestamped. As you can see, this isn't exactly the start of the request -- this doesn't count the time spent bootstrapping the application. This is one way that looking into all these lower level details can pay off. While I'm sure most devs would realize that this request start timestamp isn't "exact", these exact details might be relevant in diagnosing performance bottlenecks.

$this->app->instance('request', $request);
Facade::clearResolvedInstance('request');
$this->bootstrap();
return (new Pipeline($this->app))
    ->send($request)
    ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
    ->then($this->dispatchToRouter());

There's some low-level details we can ignore for now -- the key is that the logical flow eventually ends up at $this->dispatchToRouter.

Finally, we see how the application logic flows into the basic Router class!

return function ($request) {
  $this->app->instance('request', $request);
  return $this->router->dispatch($request);
};

Although I haven't yet delved into every corner, you can see the basic steps in how a request is captured, and now how that $request is passed into the Router instance.

Ready for a break? Go get a treat. Now let's look at the Router class itself.

The Router Class

The dispatch method eventually resolves to this basic logic:

return $this->runRoute($request, $this->findRoute($request));

To "run" a Route, it first tries to find a match. This eventually leads to the "createRoute" method. Even though it's trying to "match" against an "existing" route, it still needs to create a route object instance. Here, we see the logical hook to match the given request to an actual controller.

// If the route is routing to a controller we will parse the route action into
// an acceptable array format before registering it and creating this route
// instance itself. We need to build the Closure that will call this out.
if ($this->actionReferencesController($action)) {
   $action = $this->convertToControllerAction($action);
} 

The $action variable is an array, which is set (in "convertToControllerAction") like this:

$action['controller'] = $action['uses'];

We use this array to finally create the actual Route instance, like this:

return (new Route($methods, $uri, $action))
    ->setRouter($this)
    ->setContainer($this->container);

So now we finally see how the Route object is instantiated by the Router class. The logic returns to "RunRouteWithinStack" (in Router.php) that has a familiar call to instantiate a new Pipeline:

return (new Pipeline($this->container))
    ->send($request)
    ->through($middleware)
    ->then(fn ($request) => $this->prepareResponse(
        $request, $route->run()
    ));

This is also where middleware is gathered into the $middleware variable, via the "gatherRouteMiddleware" method (still in Router.php). Also note the closure around the prepareResponse method, ensuring a clean scope around the core application.

The Route Class

The key here is the $route->run() method -- where control finally flows to the Route instance itself.

Let's look at the Route run method. I say "let's" as if you're following along, but you're probably just skimming. That's okay, too.

    public function run()
    {
        $this->container = $this->container ?: new Container;


        try {
            if ($this->isControllerAction()) {
                return $this->runController();
            }


            return $this->runCallable();
        } catch (HttpResponseException $e) {
            return $e->getResponse();
        }
    }

The run method is fairly obvious. Either we dispatch the action to a controller or we run a non-controller route action. When this "returns", control goes back to the closure in the Router (the previous code block), specifically via the "prepareResponse" method, which dispatches a response as an event. Maybe I will do a deeper dive on how this event pipeline works in the future! Doesn't that sound like mouth-watering fun?

Honestly, there's even more depth we could go into just for routing, because life is complicated and so are applications. That said, I will end with a quick look at the ControllerDispatcher that is utilized in the runController method invoked above:

$parameters = $this->resolveParameters($route, $controller, $method);

if (method_exists($controller, 'callAction')) {
   return $controller->callAction($method, $parameters);
}


return $controller->{$method}(...array_values($parameters));

Here, finally, we see that the code invokes $controller->{$method}... to execute the controller action. For anyone that's made a custom router, this probably looks very familiar.

This uses PHP's nifty "variable functions", which allow you to easily invoke a function by using a variable. As the docs note, if a variable has parenthesis appended, it will simply attempt to execute the function for whatever function name that variable evaluates to.

It's also worth noting the "callAction" logic. You can overwrite this method to create custom routing at a controller level. While I haven't personally seen a reason to do this, it's a nice reminder that this functionality does exist.

Conclusion

Basically, Laravel follows these steps in handling a request:

  1. Requests flow to public/index.php
  2. This checks if the site is under maintenance, bootstraps the application, and initializes the autoloader
  3. This then creates a Symfony request object and creates an Illuminate Kernel instance to process the rest of the request
  4. The illuminate Kernel invokes the Router.php class to map the $request to a matching controller (or method), then returns the $response.

The truth is, you can easily use Laravel at an expert level without ever having to look at this framework logic. That's the whole point of using a framework -- leveraging battle-proven abstractions of low level boilerplate like routing.

Still, it's worth researching the lower level details of Laravel's routing to better understand the context of the application as a whole.

Laravel Deep Dives: The Service Container »
Written By:
Eric Koyanagi

I've been a software engineer for over 15 years, working in both startups and established companies in a range of industries from manufacturing to adtech to e-commerce. Although I love making software, I also enjoy playing video games (especially with my husband) and writing articles.

Article Home | My Resume | My LinkedIn
© All Rights Reserved