Coming soonThe Magento Conductor

Join the waitlist
How Magento 2's Request-Response Lifecycle Works

How Magento 2's Request-Response Lifecycle Works

Find out how Magento's routing process works, as we go over how an incoming request comes in, gets matched to a router, and returns a response with a controller.

A Magento page request passes through Nginx, pub/index.php, the bootstrap, the front controller, a list of routers sorted by sortOrder, and one controller's execute(). The result object then renders into a response, and sendResponse() hands it back. Each hop is a place your code can step in, and each is a place things quietly go wrong.

Here's the whole trip at a glance, before we walk through each stop:

  1. The browser sends an HTTP request, which includes the URL, HTTP method, headers and other metadata.
  2. Nginx matches a server block and hands anything that isn't a static file to pub/index.php through PHP-FPM.
  3. pub/index.php bootstraps Magento, creates the Http application and launches it.
  4. The application picks the area (frontend or adminhtml) from the URL and loads that area's DI config.
  5. The front controller (Magento\Framework\App\FrontController) loops through the routers in sortOrder: urlrewrite (20), standard (30), cms (60), and default (100).
  6. The standard router resolves the front name to a module through routes.xml, asks ActionList for one controller class, and creates it with the action factory.
  7. The front controller validates the request, fires the predispatch events, and calls the controller's execute().
  8. If nothing matches, the default router and the "no route" handlers send the request to the 404 page.
  9. The controller returns a result object, which renders itself into the response, with the page cache plugins wrapped around it.
  10. The bootstrap calls $response->sendResponse(), and the headers and body go back out through Nginx to the browser.

This is one stop on the Magento developer training path, and there's also a lesson on the request-response lifecycle if you'd rather watch it. Let's start where it all starts: the web browser.

ℹ️ Requests can also start from the command line (like the bin/magento cron:run that powers Magento cron) or from an API call. In this article, we're focusing solely on requests initiated from the web browser.

The Web Browser Request

When you open your favorite web browser, type in a URL, or click on a link, …you're setting off a chain of events. This ultimately leads to the display of some output on your screen.

But if you've ever stopped to think about what's happening behind the scenes, it's… a lot. But everything starts with that initial request.

When you enter a URL like https://www.example.com/products/awesome-widget into your browser's address bar and submit it, it kicks off the process. The browser takes that URL and uses it to craft an HTTP request, that is then sent to a server (after first figuring out how DNS converts domain names to IP addresses). This request is made up of a few components.

The first part of this request is the HTTP method. This is usually GET for standard web requests, or POST when submitting a form. The request also includes not only the URL, but the headers, which are sort of like metadata linked to the request.

These headers contain information about the request such as the type of browser that initiated the request, whether it was referred from another site, and the type of data that is being passed along with it.

Magento request headers

The TLS Handshake

If you're using HTTPS (which just about every site does by default these days), the browser first needs to establish a secure connection with the server. If you typed a plain http:// URL, the server usually answers with a 301 redirect to the https:// version first, so the URL that actually gets requested isn't always the one you typed.

Setting up that secure connection is called the TLS handshake. The browser and server exchange information and agree on cryptographic keys, which encrypt any data that is sent between them.

Once a secure connection is established, the browser then sends the actual request for the page. You may be thinking, "the server can now just send a response back, cool!", but this is just the first step in the process.

Internet Randomness

When your request is sent, it needs to travel through many different servers, hopping over from one network and server to the other, until it reaches the correct server. Along the trip, it may come across proxy servers, firewalls, or other sorts of obstacles that can affect its path.

It's like the digital version of Oregon Trail, except instead of dysentery, the request has to worry about network congestion and dropped packets.

Dropped packets

But let's assume our request survives the trip and makes it to the final destination. That server then acts as a sort of dispatcher. It sorts through all incoming requests, including the one we just made, and figures out how to handle them.

In the case of Magento, the first stop of the request is the web server, which is usually either Nginx or Apache (though it could be any other web server that also supports PHP). The web server acts as a greeter which then decides what to do with the request.

Most Magento sites use Nginx as their web server, as it is more performant, scalable, and easier to configure than Apache. We will assume this is the case for the rest of the trip.

Nginx: The First Point of Contact

So, our little HTTP request made its way across the internet and arrived at the web server. This is usually its first point of contact, and it acts as the gatekeeper that determines where to send the request.

In a Magento stack, Nginx is the web server that serves static files itself and hands PHP requests to PHP-FPM over FastCGI. On production stores there is often something in front of it too: Varnish as a reverse proxy cache, and sometimes another Nginx or a load balancer terminating SSL in front of Varnish. Those layers can answer a request without Magento ever seeing it, so keep them in mind when a change "isn't showing up".

Server Blocks

When Nginx receives the request, it looks at the server configuration to determine how to process it. The config expects a server block to be defined in this configuration, which acts as a virtual host.

Back when I started coding, the concept of virtual hosts didn't exist, and there could only be one website defined for one server. Since then, virtual hosts were created, which allow you to host multiple websites on a single server, which saves a lot of time and money so you don't need to stand up a separate web server for each website.

Each of these server blocks has its own related configuration values, which tells Nginx how to handle requests for the related site.

Here's the configuration used in my docker-magento project:

upstream fastcgi_phpfpm {
  server unix:/sock/phpfpm.sock;
}
 
upstream fastcgi_phpfpm_xdebug {
  server unix:/sock/phpfpm-xdebug.sock;
}
 
map $cookie_XDEBUG_SESSION $fastcgi_backend {
  default fastcgi_phpfpm;
  PHPSTORM fastcgi_phpfpm_xdebug;
}
 
server {
  listen 8000;
  return 301 https://$host$request_uri;
}
 
server {
  listen [::]:8443 ssl http2 ipv6only=on;
  listen 8443 ssl http2;
 
  ssl_certificate /etc/nginx/certs/nginx.crt;
  ssl_certificate_key /etc/nginx/certs/nginx.key;
 
  set $MAGE_ROOT /var/www/html;
 
  fastcgi_buffer_size 64k;
  fastcgi_buffers 8 128k;
 
  location /livereload.js {
    proxy_set_header Host $host;
    proxy_pass http://phpfpm:35729/livereload.js;
  }
 
  location /livereload {
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";
    proxy_pass http://phpfpm:35729/livereload;
  }
 
  include /var/www/html/nginx[.]conf;
}

Nginx matches the incoming request to one of these server blocks based on the port it came in on and the server name. Once it has matched a request to a server block, it starts processing it according to the rules defined in that block. You can see the HTTP-to-HTTPS redirect from earlier right there in the first block on port 8000.

Magento's Nginx Configuration

Note that the last line in that configuration loads any files within our project location, /var/www/html, that start with nginx.conf, which includes the nginx.conf.sample file included in Magento (simplified below for brevity):

## ...
 
root $MAGE_ROOT/pub;
 
index index.php;
autoindex off;
charset UTF-8;
error_page 404 403 = /errors/404.php;
 
## ...
 
location / {
    try_files $uri $uri/ /index.php$is_args$args;
}
 
location /pub/ {
    location ~ ^/pub/media/(downloadable|customer|import|custom_options|theme_customization/.*\.xml) {
        deny all;
    }
    alias $MAGE_ROOT/pub/;
    add_header X-Frame-Options "SAMEORIGIN";
}
 
## ...

The document root is pub, not the Magento root, so nothing outside pub (like app/etc/env.php) can be requested directly. The location / block contains the line that does the real work: try_files $uri $uri/ /index.php$is_args$args;.

That line tells Nginx to serve the file if it exists on disk (an image, a CSS file, a static asset), which bypasses Magento's PHP code entirely. Anything else falls through to index.php, with the original path and query string intact. This is where things start to get interesting 🤓

Where did index.php go?

You might expect Nginx to be rewriting https://www.example.com/index.php/products/awesome-widget into https://www.example.com/products/awesome-widget with a regex rule. It isn't. Magento's Nginx config has no rule that strips index.php. The try_files fallback sends every non-file request to index.php already, and Magento reads the original path from the request.

Whether the URLs Magento generates contain index.php is a Magento setting, not an Nginx one: Stores > Configuration > General > Web > Search Engine Optimization > Use Web Server Rewrites. Set it to "Yes" and Magento builds links without index.php, trusting the web server to route them back.

The .php location block in that same file passes the request to PHP-FPM through fastcgi_pass, along with the URL, method and headers. PHP-FPM executes Magento, and Magento generates the response that Nginx sends back to the user.

Bootstrapping Magento

Every storefront and admin page request lands in the same file, pub/index.php. Once you strip out the error handling, it's three lines:

pub/index.php
<?php
 
use Magento\Framework\App\Bootstrap;
 
require __DIR__ . '/../app/bootstrap.php';
 
$bootstrap = Bootstrap::create(BP, $_SERVER);
$app = $bootstrap->createApplication(\Magento\Framework\App\Http::class);
$bootstrap->run($app);

Here's what happens in those lines:

  1. app/bootstrap.php pulls in app/autoload.php, which registers the Composer autoloader and defines the BP constant (the Magento root path).
  2. Bootstrap::create() builds the object manager from app/etc/env.php and app/etc/config.php. From this point on, everything is created through DI.
  3. createApplication() asks the object manager for the Magento\Framework\App\Http application.
  4. run() checks maintenance mode and that Magento is installed, then calls $app->launch(). Whatever launch() returns gets sent with $response->sendResponse().

The interesting part is Http::launch():

vendor/magento/framework/App/Http.php
// ...
public function launch()
{
    $areaCode = $this->_areaList->getCodeByFrontName($this->_request->getFrontName());
    $this->_state->setAreaCode($areaCode);
    $this->_objectManager->configure($this->_configLoader->load($areaCode));
    /** @var \Magento\Framework\App\FrontControllerInterface $frontController */
    $frontController = $this->_objectManager->get(\Magento\Framework\App\FrontControllerInterface::class);
    $result = $frontController->dispatch($this->_request);
    // TODO: Temporary solution until all controllers return ResultInterface (MAGETWO-28359)
    if ($result instanceof ResultInterface) {
        $this->registry->register('use_page_cache_plugin', true, true);
        $result->renderResult($this->_response);
    } elseif ($result instanceof HttpInterface) {
        $this->_response = $result;
    } else {
        throw new \InvalidArgumentException('Invalid return type');
    }
    // ...
    $eventParams = ['request' => $this->_request, 'response' => $this->_response];
    $this->_eventManager->dispatch('controller_front_send_response_before', $eventParams);
    return $this->_response;
}
// ...

The first line picks the area. The first segment of the URL is compared against each area's front name: if it matches the admin front name (the backend/frontName value in env.php), the area is adminhtml; otherwise it falls back to frontend. The object manager is then reconfigured with that area's di.xml files, which is why a plugin declared in etc/frontend/di.xml never fires in the admin.

Only after all of that does the front controller get the request. Notice that launch() is also where the response half happens, right after dispatch() returns. We'll come back to that at the end.

Request Routing in Magento 2

At this point we have a fully initialized Magento app, with the area set and its configuration loaded. But how does Magento know what to do with the request?

That's where routing comes into play 😄

The Front Controller

In apps, routing is usually handled by what's known as a "front controller", which is the first place of entry for a request. Magento is no different. The front controller is responsible for taking the incoming request and figuring out how to handle it.

The process is the same whether it is a route that comes standard in Magento, or if you create a custom route. All requests go through the same process.

In Magento, the front controller class is located at Magento\Framework\App\FrontController. This file is the brains behind the routing process. When the front controller receives a request, it works with some other components to determine the route that is responsible for handling it.

The dispatch method of the FrontController class is the part of code which executes an action to generate a response:

vendor/magento/framework/App/FrontController.php
<?php
 
namespace Magento\Framework\App;
 
// ...
 
class FrontController implements FrontControllerInterface
{
    // ...
 
    public function dispatch(RequestInterface $request)
    {
        Profiler::start('routers_match');
        $this->validatedRequest = false;
        $routingCycleCounter = 0;
        $result = null;
        while (!$request->isDispatched() && $routingCycleCounter++ < 100) {
            /** @var \Magento\Framework\App\RouterInterface $router */
            foreach ($this->_routerList as $router) {
                try {
                    $actionInstance = $router->match($request);
                    if ($actionInstance) {
                        $result = $this->processRequest(
                            $request,
                            $actionInstance
                        );
                        break;
                    }
                } catch (\Magento\Framework\Exception\NotFoundException $e) {
                    $request->initForward();
                    $request->setActionName('noroute');
                    $request->setDispatched(false);
                    break;
                }
            }
        }
        Profiler::stop('routers_match');
        if ($routingCycleCounter > 100) {
            throw new \LogicException('Front controller reached 100 router match iterations');
        }
        return $result;
    }
 
    // ...
}

This code loops through all of the available routers and asks each one, in order, whether it can match() the request. The first router that returns an action instance wins. In Magento, the standard router is the main router that checks for a related controller action, which will then handle the request.

Note the outer while loop. A router can modify the request and return a Forward action instead of a real controller, which leaves the request undispatched. The front controller then starts over at the top of the router list with the modified request. The 100-iteration limit is there to stop a bad forward from looping forever.

To learn more about custom routers and advanced routing, check out the Magento 2 Router Wizardry course, which goes into this in extreme detail.

The Request Object

A key player in the routing process is the request object, which is an instance of Magento\Framework\App\Request\Http. The request object contains information about the incoming request, including the URL, query params, HTTP headers, and more.

This class contains many helper functions which store information about the request, return full request names, and so on:

vendor/magento/framework/App/Request/Http.php
<?php
 
namespace Magento\Framework\App\Request;
 
// ...
 
class Http extends Request implements
    RequestContentInterface,
    RequestSafetyInterface,
    HttpRequestInterface,
    ResetAfterRequestInterface
{
    // ...
 
    public function initForward()
    {
        if (empty($this->beforeForwardInfo)) {
            $this->beforeForwardInfo = [
                'params' => $this->getParams(),
                'action_name' => $this->getActionName(),
                'controller_name' => $this->getControllerName(),
                'module_name' => $this->getModuleName(),
                'route_name' => $this->getRouteName(),
            ];
        }
        return $this;
    }
 
    // ...
 
    public function getFullActionName($delimiter = '_')
    {
        return $this->getRouteName() .
            $delimiter .
            $this->getControllerName() .
            $delimiter .
            $this->getActionName();
    }
 
    // ...
}

Magento URL Rewrites

The first router in line is the URL rewrite router. This is the rewrite process within Magento's application layer, and it's what turns SEO-friendly URLs like /awesome-widget.html into internal paths like catalog/product/view/id/42, or redirects an old URL to a new one.

In Magento 2, the URL rewrite process is handled by the Magento\UrlRewrite\Controller\Router class, and is processed before the standard router.

This URL rewrite router class has a match method that takes the current request object as a parameter. This method is called by the front controller during the routing process, and it determines if the current requesting URL matches any of the rows in the url_rewrite table.

Here is that match method from this router class:

vendor/magento/module-url-rewrite/Controller/Router.php
<?php
 
namespace Magento\UrlRewrite\Controller;
 
// ...
use Magento\Framework\App\RouterInterface;
// ...
 
class Router implements RouterInterface
{
    // ...
 
    public function match(RequestInterface $request)
    {
        $rewrite = $this->getRewrite(
            $request->getPathInfo(),
            $this->storeManager->getStore()->getId()
        );
 
        if ($rewrite === null) {
            // No rewrite rule matching current URl found, continuing with
            // processing of this URL.
            return null;
        }
 
        $requestStringTrimmed = ltrim($request->getRequestString() ?? '', '/');
        $rewriteRequestPath = $rewrite->getRequestPath();
        $rewriteTargetPath = $rewrite->getTargetPath() ?? '';
        $rewriteTargetPathTrimmed = ltrim($rewriteTargetPath, '/');
 
        if (preg_replace('/\?.*/', '', $rewriteRequestPath) === preg_replace('/\?.*/', '', $rewriteTargetPath) &&
            (
                !$requestStringTrimmed ||
                !$rewriteTargetPathTrimmed ||
                strpos($requestStringTrimmed, $rewriteTargetPathTrimmed) === 0
            )
        ) {
            // Request and target paths of rewrite found without query params are equal and current request string
            // starts with request target path, continuing with processing of this URL.
            return null;
        }
 
        if ($rewrite->getRedirectType()) {
            // Rule requires the request to be redirected to another URL
            // and cannot be processed further.
            return $this->processRedirect($request, $rewrite);
        }
        // Rule provides actual URL that can be processed by a controller.
        $request->setAlias(
            UrlInterface::REWRITE_REQUEST_PATH_ALIAS,
            $rewriteRequestPath
        );
        $request->setPathInfo('/' . $rewriteTargetPath);
        return $this->actionFactory->create(
            Forward::class
        );
    }
 
    // ...
}

In this method, the router tries to find a matching URL rewrite for the current request. If a match is found, it checks if the rewrite is a redirect, and if so, the router processes the redirect and returns a redirect response.

If it is not a redirect, the router swaps the request's path for the rewrite's target path and returns a Forward action. That's the forward loop from earlier in action: the front controller starts over, and this time the standard router matches catalog/product/view.

This router class is configured in the etc/frontend/di.xml file of the Magento_UrlRewrite module:

vendor/magento/module-url-rewrite/etc/frontend/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\App\RouterList">
        <arguments>
            <argument name="routerList" xsi:type="array">
                <item name="urlrewrite" xsi:type="array">
                    <item name="class" xsi:type="string">Magento\UrlRewrite\Controller\Router</item>
                    <item name="disable" xsi:type="boolean">false</item>
                    <item name="sortOrder" xsi:type="string">20</item>
                </item>
            </argument>
        </arguments>
    </type>
    <!-- ... -->
</config>

As you can see, the Magento\UrlRewrite\Controller\Router class has a sort order of 20.

The Magento Store module's etc/frontend/di.xml file also adds some additional routers to this list, including the standard router and default router, with the sort orders of 30 and 100, respectively:

vendor/magento/module-store/etc/frontend/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <!-- ... -->
    <type name="Magento\Framework\App\RouterList" shared="true">
        <arguments>
            <argument name="routerList" xsi:type="array">
                <item name="standard" xsi:type="array">
                    <item name="class" xsi:type="string">Magento\Framework\App\Router\Base</item>
                    <item name="disable" xsi:type="boolean">false</item>
                    <item name="sortOrder" xsi:type="string">30</item>
                </item>
                <item name="default" xsi:type="array">
                    <item name="class" xsi:type="string">Magento\Framework\App\Router\DefaultRouter</item>
                    <item name="disable" xsi:type="boolean">false</item>
                    <item name="sortOrder" xsi:type="string">100</item>
                </item>
            </argument>
        </arguments>
    </type>
</config>

This means that the UrlRewrite router (with the sort order of 20) is processed before the standard router, and that the Magento\Framework\App\Router\DefaultRouter class with a sort order of 100 catches the request if it isn't matched to any other routers.

What order do Magento routers run in?

Putting the di.xml files together, here is the full router list for each area in core Magento:

AreaRouterClasssortOrder
frontendurlrewriteMagento\UrlRewrite\Controller\Router20
frontendstandardMagento\Framework\App\Router\Base30
frontendcmsMagento\Cms\Controller\Router60
frontenddefaultMagento\Framework\App\Router\DefaultRouter100
adminhtmladminMagento\Backend\App\Router10
adminhtmldefaultMagento\Framework\App\Router\DefaultRouter100

A custom router is just another item in this list. Pick a sortOrder that places it before the router that would otherwise grab the URL.

Route Matching

The standard router (Magento\Framework\App\Router\Base) is where most requests get matched to a controller.

The standard router's match method parses the params, and then calls matchAction:

vendor/magento/framework/App/Router/Base.php
// ...
public function match(\Magento\Framework\App\RequestInterface $request)
{
    $params = $this->parseRequest($request);
 
    return $this->matchAction($request, $params);
}
// ...

…and then matchAction looks up which modules registered the URL's front name, and attempts to match the request to a controller action in one of them:

vendor/magento/framework/App/Router/Base.php
// ...
protected function matchAction(\Magento\Framework\App\RequestInterface $request, array $params)
{
    $moduleFrontName = $this->matchModuleFrontName($request, $params['moduleFrontName']);
    if (!strlen((string) $moduleFrontName)) {
        return null;
    }
 
    /**
     * Searching router args by module name from route using it as key
     */
    $modules = $this->_routeConfig->getModulesByFrontName($moduleFrontName);
    if (empty($modules) === true) {
        return null;
    }
 
    /**
     * Going through modules to find appropriate controller
     */
    $currentModuleName = null;
    $actionPath = null;
    $action = null;
    $actionInstance = null;
 
    $actionPath = $this->matchActionPath($request, $params['actionPath']);
    $action = $request->getActionName() ?: ($params['actionName'] ?: $this->_defaultPath->getPart('action'));
    $this->_checkShouldBeSecure($request, '/' . $moduleFrontName . '/' . $actionPath . '/' . $action);
 
    foreach ($modules as $moduleName) {
        $currentModuleName = $moduleName;
 
        $actionClassName = $this->actionList->get($moduleName, $this->pathPrefix, $actionPath, $action);
        if (!$actionClassName || !is_subclass_of($actionClassName, $this->actionInterface)) {
            continue;
        }
 
        $actionInstance = $this->actionFactory->create($actionClassName);
        break;
    }
 
    if (null == $actionInstance) {
        $actionInstance = $this->getNotFoundAction($currentModuleName);
        if ($actionInstance === null) {
            return null;
        }
        $action = self::NO_ROUTE;
    }
 
    // set values only after all the checks are done
    $request->setModuleName($moduleFrontName);
    $request->setControllerName($actionPath);
    $request->setActionName($action);
    $request->setControllerModule($currentModuleName);
    $request->setRouteName($this->_routeConfig->getRouteByFrontName($moduleFrontName));
    if (isset($params['variables'])) {
        $request->setParams($params['variables']);
    }
    return $actionInstance;
}
// ...

getModulesByFrontName() reads every module's etc/frontend/routes.xml (or etc/adminhtml/routes.xml) to find the modules that registered that front name. Usually that's one module, but several modules can share a front name using before/after, which is how you add a new controller under an existing URL like /checkout/.

For each of those modules, the router asks the Magento\Framework\App\Router\ActionList class for a controller class. The action list is like a directory of all available controller actions in Magento.

Here's a simplified version of the ActionList class:

vendor/magento/framework/App/Router/ActionList.php
<?php
 
namespace Magento\Framework\App\Router;
 
// ...
 
class ActionList
{
    // ...
 
    public function get($module, $area, $namespace, $action)
    {
        if ($area) {
            $area = '\\' . $area;
        }
        $namespace = $namespace !== null ? strtolower($namespace) : '';
        if (strpos($namespace, self::NOT_ALLOWED_IN_NAMESPACE_PATH) !== false) {
            return null;
        }
        if ($action && in_array(strtolower($action), $this->reservedWords)) {
            $action .= 'action';
        }
        $fullPath = str_replace(
            '_',
            '\\',
            strtolower(
                $module . '\\controller' . $area . '\\' . $namespace . '\\' . $action
            )
        );
        try {
            if ($this->validateActionClass($fullPath)) {
                return $this->actions[$fullPath];
            }
        } catch (ReflectionException $e) {
            return null;
        }
 
        return null;
    }
 
    private function validateActionClass(string $fullPath): bool
    {
        if (isset($this->actions[$fullPath])) {
            if (!is_subclass_of($this->actions[$fullPath], $this->actionInterface)) {
                return false;
            }
            $reflectionClass = $this->reflectionClassFactory->create($this->actions[$fullPath]);
            if ($reflectionClass->isInstantiable()) {
                return true;
            }
        }
        return false;
    }
}

The get method builds a lowercase path from the module name, the controller folder and the action name (for example magento\catalog\controller\product\view), then looks it up in the list of every controller class Magento knows about. validateActionClass() doesn't create anything. It only checks, using reflection, that the class implements ActionInterface and can be instantiated. get() returns the class name, or null.

The instance is created back in matchAction(), with $this->actionFactory->create($actionClassName), and that instance is what match() hands back to the front controller.

The Anatomy of a Magento URL

Each route in Magento 2 is defined using a combination of the front name, controller name, and action name. For example, a route for the product view page looks like catalog/product/view. The standard router uses these three parts to determine which controller action will handle the request.

Magento 2 anatomy of a URL

Dispatching the Request

Once a router returns an action, the front controller's processRequest() takes over. It loads the area's design and translations, runs the request validators (this is where CSRF checks on POST requests and HTTP method checks like HttpGetActionInterface happen), fires the controller_action_predispatch events, calls the controller, and then fires the controller_action_postdispatch events.

The controller action is responsible for processing the request, and then generating the response. Learning how to create a controller in Magento is a fundamental skill for every Magento developer.

Here's an example of a very simple controller action class, which returns a Page object:

app/code/Vendor/Module/Controller/Index/Index.php
<?php
 
declare(strict_types=1);
 
namespace Vendor\Module\Controller\Index;
 
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;
 
class Index implements HttpGetActionInterface
{
    public function __construct(
        private PageFactory $pageFactory,
    ) {}
 
    public function execute(): Page
    {
        return $this->pageFactory->create();
    }
}

The execute method is where the action happens. It's responsible for processing the request, interacting with any models or services that it needs to, and then returning a result. Note that it doesn't echo anything or touch the response directly. It hands back a result object, and the response half of the lifecycle takes it from there.

What happens when no route matches?

If none of the earlier routers match, the default router (sortOrder 100) catches the request and delegates it to the "no route" handlers. These handlers act as a safety net that catch any requests that weren't able to get matched to another router.

Magento 2 has separate no route handlers for the frontend and backend areas:

Frontend No Route Handler

Within the frontend no route handler's process method, it gets the configuration value of web/default/no_route, which is declared in vendor/magento/module-backend/etc/adminhtml/system.xml.

It then rewrites the request to that route, and the front controller's loop runs the routers again:

vendor/magento/framework/App/Router/NoRouteHandler.php
// ...
public function process(\Magento\Framework\App\RequestInterface $request)
{
    $noRoutePath = $this->_config->getValue('web/default/no_route', 'default');
 
    if ($noRoutePath) {
        $noRoute = explode('/', $noRoutePath);
    } else {
        $noRoute = [];
    }
 
    $moduleName = isset($noRoute[0]) ? $noRoute[0] : 'core';
    $actionPath = isset($noRoute[1]) ? $noRoute[1] : 'index';
    $actionName = isset($noRoute[2]) ? $noRoute[2] : 'index';
 
    $request->setModuleName($moduleName)->setControllerName($actionPath)->setActionName($actionName);
 
    return true;
}
// ...

Backend No Route Handler

Similarly, the backend no route handler works in basically the same way, catching requests that fail to match a backend route within a process method. But rather than having the no route path defined within a system configuration value, it is hard-coded to adminhtml/noroute/index:

vendor/magento/module-backend/App/Router/NoRouteHandler.php
// ...
public function process(\Magento\Framework\App\RequestInterface $request)
{
    $requestPathParams = explode('/', trim($request->getPathInfo(), '/'));
    $areaFrontName = array_shift($requestPathParams);
 
    if ($areaFrontName === $this->helper->getAreaFrontName(true)) {
        $moduleName = $this->routeConfig->getRouteFrontName('adminhtml');
        $actionNamespace = 'noroute';
        $actionName = 'index';
        $request->setModuleName($moduleName)->setControllerName($actionNamespace)->setActionName($actionName);
        return true;
    }
    return false;
}
// ...

CMS No Route Handler

CMS pages don't officially have a "no route" handler, however there is a fallback mechanism defined that is important to mention. The CMS router (Magento\Cms\Controller\Router) is responsible for handling routes specific to CMS pages.

This router is defined in the etc/frontend/di.xml file of the Magento_Cms module:

vendor/magento/module-cms/etc/frontend/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\App\RouterList">
        <arguments>
            <argument name="routerList" xsi:type="array">
                <item name="cms" xsi:type="array">
                    <item name="class" xsi:type="string">Magento\Cms\Controller\Router</item>
                    <item name="disable" xsi:type="boolean">false</item>
                    <item name="sortOrder" xsi:type="string">60</item>
                </item>
            </argument>
        </arguments>
    </type>
</config>

The CMS router checks if the request URL matches any CMS pages and, if a match is found, dispatches the request to the appropriate CMS controller action (for more info about this, see the match method of this class).

The CMS module has a related fallback defined for non-matching routes. The "CMS No-Route Page" is defined in the web/default/cms_no_route configuration setting declared in vendor/magento/module-cms/etc/adminhtml/system.xml.

This is the page that renders when no route matches at all: the default web/default/no_route value is cms/noroute/index, and that controller displays whichever CMS page is set as the "CMS No-Route Page".

The Response

Routing gets all the attention, but the title says request-response, so let's finish the trip. Head back to Http::launch() and look at what happens after dispatch() returns.

The result object renders itself

Modern controllers return a result object: a Page, Json, Raw, Redirect or Forward. launch() calls $result->renderResult($this->_response) on it, which applies any HTTP headers set on the result and then lets the result write itself into the response object:

  • A Page result builds the layout (handles, XML, blocks, templates) and appends the rendered HTML to the response body. This is where all of your layout XML and .phtml work actually executes.
  • A Json result encodes its data and sets the Content-Type header.
  • A Redirect result sets a Location header and a 302 status (or whatever code you set).

Older controllers that return the $response object directly still work. launch() just uses that response as-is.

The full page cache plugins

The front controller is wrapped by plugins from Magento_PageCache in etc/frontend/di.xml. With the built-in cache, BuiltinPlugin::aroundDispatch() checks the cache before routing even starts. On a hit, it returns the cached response, and no router or controller runs at all. On a miss, a second plugin on ResultInterface stores the rendered page after renderResult(). With Varnish, the equivalent plugins add the cache headers and tags that Varnish needs.

This is also why around plugins on FrontControllerInterface are such a sensitive spot: everything else on the page runs inside them. If you're adding your own, read up on Magento plugin execution order first, and run bin/magento dev:di:info "Magento\Framework\App\FrontControllerInterface" frontend to see what's already wrapped around it (more on dev:di:info).

Sending the response

Right before returning, launch() dispatches the controller_front_send_response_before event, which is your last chance to touch the response (setting a cookie or adding a header) with an observer. Then control goes back to Bootstrap::run(), which calls $response->sendResponse(). PHP sends the status line, headers and body to PHP-FPM, PHP-FPM hands them to Nginx, and Nginx sends them back to the browser (through Varnish or a load balancer, if you have one). The browser renders the page, then fires off more requests for the CSS, JS and images, and most of those are served by Nginx straight from pub/static and pub/media without Magento getting involved at all.

Wrapping up

Routing is the most critical part of Magento's request-response lifecycle, because it decides which controller handles the request. But the bootstrap before it decides which configuration that controller runs with, and the response half after it decides what actually reaches the browser. When a page misbehaves, knowing which of those three stages you're in cuts the debugging in half.

Building a custom router can be useful if you want Magento to do something it does not do out of the box. In my How To Build a Custom Router in Magento 2 for Dashes video (embedded below), it was handling special characters within URLs. The Magento 2 Router Wizardry course takes it from there.

Something here out of date?

Magento moves, and so does this site. If a step no longer works, tell me and I'll fix it.

Get the next one by email

Every other week I send a short newsletter on how PHP + Magento actually work: releases, security alerts, and one thing explained under the hood. Enroll in the Library and it's yours. No credit card needed.

The University includes every PHP + Magento course available, and every new M.academy course as it ships.