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:
- The browser sends an HTTP request, which includes the URL, HTTP method, headers and other metadata.
- Nginx matches a server block and hands anything that isn't a static file to
pub/index.phpthrough PHP-FPM. pub/index.phpbootstraps Magento, creates theHttpapplication and launches it.- The application picks the area (
frontendoradminhtml) from the URL and loads that area's DI config. - The front controller (
Magento\Framework\App\FrontController) loops through the routers insortOrder:urlrewrite(20),standard(30),cms(60), anddefault(100). - The
standardrouter resolves the front name to a module throughroutes.xml, asksActionListfor one controller class, and creates it with the action factory. - The front controller validates the request, fires the predispatch events, and calls the controller's
execute(). - If nothing matches, the default router and the "no route" handlers send the request to the 404 page.
- The controller returns a result object, which renders itself into the response, with the page cache plugins wrapped around it.
- 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:runthat 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.
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.
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:
<?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:
app/bootstrap.phppulls inapp/autoload.php, which registers the Composer autoloader and defines theBPconstant (the Magento root path).Bootstrap::create()builds the object manager fromapp/etc/env.phpandapp/etc/config.php. From this point on, everything is created through DI.createApplication()asks the object manager for theMagento\Framework\App\Httpapplication.run()checks maintenance mode and that Magento is installed, then calls$app->launch(). Whateverlaunch()returns gets sent with$response->sendResponse().
The interesting part is Http::launch():
// ...
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:
<?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:
<?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:
<?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:
<?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:
<?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:
| Area | Router | Class | sortOrder |
|---|---|---|---|
| frontend | urlrewrite | Magento\UrlRewrite\Controller\Router | 20 |
| frontend | standard | Magento\Framework\App\Router\Base | 30 |
| frontend | cms | Magento\Cms\Controller\Router | 60 |
| frontend | default | Magento\Framework\App\Router\DefaultRouter | 100 |
| adminhtml | admin | Magento\Backend\App\Router | 10 |
| adminhtml | default | Magento\Framework\App\Router\DefaultRouter | 100 |
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:
// ...
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:
// ...
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:
<?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.
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:
<?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:
// ...
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:
// ...
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:
<?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
Pageresult 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.phtmlwork actually executes. - A
Jsonresult encodes its data and sets theContent-Typeheader. - A
Redirectresult sets aLocationheader 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.



