Magento 2 Tutorials

A quick look at the Magento 2 NoRouteHandler system

Magento 2 404 page with stupid effect

During some Magento 2 playtime, we stumbled upon an internal system which is probably best described as the NoRouteHandler system. Here’s what we discovered.

Introduction

When working on a port of Alan Storm’s Better404 module for Magento 2, the NoRouteHandler system was stumbled upon. Initially, it was ignored in favor of the event system to catch 404 pages, but after further examination it felt more like the correct way to replace the default 404 functionality.

The correct way is completely subjective here because there are a handful of ways to handle custom 404 pages in Magento 2.

Magento 2 Dispatching

At a (very distant) helicopter view, request dispatching of Magento 1 and 2 isn’t that different: the front controller iterates over available routers and gives them the opportunity to execute their match-logic. As long as the request hasn’t been marked as dispatched, the router list keeps getting iterated over until that has been done a 100 times at which point an exception is thrown:

// lib/internal/Magento/Framework/App/FrontController.php - dispatch()
while (!$request->isDispatched() && $routingCycleCounter++ < 100) { /** @var \Magento\Framework\App\RouterInterface $router */ foreach ($this->_routerList as $router) {
try {
$actionInstance = $router->match($request);
if ($actionInstance) {
$request->setDispatched(true);
$actionInstance->getResponse()->setNoCacheHeaders();
$result = $actionInstance->dispatch($request);
break;
}
} catch (Action\NotFoundException $e) {
$request->initForward();
$request->setActionName('noroute');
$request->setDispatched(false);
break;
}
}
}
\Magento\Framework\Profiler::stop('routers_match');
if ($routingCycleCounter > 100) {
throw new \LogicException('Front controller reached 100 router match iterations');
}

Routers which want to get involved in the routing process should be injected into the shared Magento\Framework\App\RouterList object. Due to Magento 2 its dependency injection system, each and every module is allowed to do this. For example, the Magento_Core Magento_Store module supplies the Base and DefaultRouter:

<!-- Magento/Core/etc/frontend/di.xml -->

Magento\Core\App\Router\Base
false
20

Magento\Framework\App\Router\DefaultRouter
false
100

Routers are listed based on the sortOrder item, this happens in ascending order. Given those two routers, the Base router comes before the DefaultRouter. In a default Magento 2 installation, the DefaultRouterhas the lowest priority (highest sortOrder) which means it will be the last to be asked for matches in the FrontController.

The DefaultRouter is the building stone of the NoRouteHandler system.

The DefaultRouter

All that the DefaultRouter does, is iterating over the NoRouteHandlerList and checking whether any of the registered handlers can process the request. At the end of this iteration, it returns a Forward action:

// lib/internal/Magento/Framework/App/Router/DefaultRouter.php - match()

foreach ($this-&gt;noRouteHandlerList-&gt;getHandlers() as $noRouteHandler) {
if ($noRouteHandler-&gt;process($request)) {
break;
}
}

return $this-&gt;actionFactory-&gt;create('Magento\Framework\App\Action\Forward', ['request' =&gt; $request])

What the Forward action does

The Forward action can be best explained as a joker-card: on dispatching of the Forward action, the request is marked as “not dispatched”. Looking back at the FrontController iterating over the available routers and assume no other routers were able to match, here’s what happens:

  1. foreach ($this->_routerList as $router) { — At this point $router is a DefaultRouterinstance
  2.   $actionInstance = $router->match($request); — The DefaultRouter always returns aForward action instance
  3.   if ($actionInstance) { — So we go on…
  4.     $request->setDispatched(true); — The request is marked as dispatched, thus the outer loop (while (!$request->isDispatched() ..) will stop
  5.     $result = $actionInstance->dispatch($request); — The Forward action is asked to dispatch the request
  6.     break; — the foreach loop will stop

So what makes the Forward action a joker-card? It marks the request as not dispatched:

// lib/internal/Magento/Framework/App/Action/Forward.php - dispatch()
$request-&gt;setDispatched(false);

This means that the ! $request->isDispatched() condition in the outer while-loop of theFrontController is still in effect. Because earlier the foreach-loop was break;‘d out of, the matching-process immediately begins all over again.

What it has to do with the NoRouteHandler system

Just before the Forward action is returned, the NoRouteHandlerList its entries are iterated over and given an attempt to process() the request. A NoRouteHandler is therefore able to simply alter the request and steer the routing-process in the upcoming next routing attempt.

This is exactly what the default Magento\Core\App\Router\NoRouteHandler does. It retrieves theweb/default/no_route configuration value, parses it and modifies the request to be routed towards it:

// Magento/Core/App/Router/NoRouteHandler.php -- process()
$noRoutePath = $this-&gt;_config-&gt;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-&gt;setModuleName($moduleName)-&gt;setControllerName($actionPath)-&gt;setActionName($actionName);

return true;

In Magento 1.X, there is no NoRouteHandler fallback: it is what the default router (Mage_Core_Controller_Varien_Router_Default) is responsible for. Splitting this logic from the router adheres to the separation of concerns principle and by putting the no route handlers in a list, modifying the “noroute” logic is a lot more elegant.

A Custom NoRouteHandler

Just like the RouterList, the NoRouteHandlerList can be altered by making use of dependency injection. In a module its di.xml file, the following type-definition is added:

Vendor\Module\App\Router\NoRouteHandler
1

Take note that the sortOrder should be lower than that of Magento’s default NoRouteHandler (injected inMagento/Core/etc/di.xml with a sortOrder of 100). In your custom NoRouteHandler you can do whatever you want. To comply with the standard set by the Magento 2 core code, you should arguably keep your NoRouteHandler as lightweight as possible; alter the request and point it to a “fatter” controller.

class NoRouteHandler implements NoRouteHandlerInterface
{
/**
* Check and process no route request
*
* @param \Magento\Framework\App\RequestInterface $request
* @return bool
*/
public function process(\Magento\Framework\App\RequestInterface $request)
{
// Be sure to return a true-value if you do not want the Default router to consider other no route handlers
return true;
}
}

Is this the best way to modify a “no route”?

That’s up for debate. Given the NoRouteHandler system is available, it feels like the most logical spot to hook onto for modules which want to be drop-in replacements for “no routes”.

Directly altering the web/default/no_route configuration value in, for example, a setup script modifies the integrity of a Magento installation: if the replacement-module is removed, the Magento application returns to a different state than that it was prior to module installation. It can be worked around but why bother if you have a fitting alternative?

There’s also a variety of events which can be listened to for catching “no routes” and with Magento 2 its interceptor system you can even get really creative if you want to.

Final words

Hopefully this piece of theory is useful to anyone. For those not interested in the theory, we might publish a more concrete “How to create a custom Magento 2 404 Page” in the future. In case you want to see the NoRouteHandler system in action, the code of the (currently humble) port of the Better404 module is on Github.

PS. The official name is probably not “NoRouteHandler system” but it sure sounds interesting, right?!

Subscribe Newsletter

Subscribe to get latest Magento news

40% Off for 4 Months on Magento Hosting + 30 Free Migration