How to create a custom Router in Adobe Commerce (Magento)

Adobe Commerce (Magento) provides a mechanism to declare custom routing functionality. This can be handy when you have to implement dynamic routing with user-friendly URLs. In this practical tutorial, you are going to learn how to declare and create a custom routing implementation and enable user-friendly URLs.

Routing

Routing in web application request processing is an action that is triggered for a request to find an appropriate path and route for further request processing. The processing of the request is then passed to an action controller. Adobe Commerce (Magento) uses Action Controller classes to process requests.

How Does Adobe Commerce (Magento) Routing Work?

First of all, let's understand how the routing works.

The Adobe Commerce (Magento) application uses the pub/index.php file to handle incoming user requests. The index.php script instantiates the Magento\Framework\App\Http object. The Http class represents an application. This class has the launch() method that is triggered as part of the Magento\Framework\App\Bootstrap object. The Bootstrap object is created by the index.php script.

The Http::launch() method instantiates the Front Controller (Magento\Framework\App\FrontController class) and triggers the dispatch() method. The dispatch() method goes through the registered list of routers. A router checks for a match between the Request object and its parameters and possible Action Controller.

In case one of the router classes finds an Action Controller that can process the request, it further passes an instance of the Action Controller back to the Front Controller. The Front Controller triggers the execute() method from the Action Controller object. The result from the execute() method is then returned to the Http object for rendering the result to a user.

Pic 1. Request Handling Sequence Diagram

Front Controller and Router List

For the Front Controller to get the list of registered routers in the application, it uses the Magento\Framework\App\RouterList class. The RouterList class is used as a container for a list of routers. As Adobe Commerce (Magento) uses the XML configuration for... everything, we can add our custom Router class to the routerList array of routers.

Here is an example of the cms router:

xml
<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>

The Front Controller reads registered in the application routers from the RouterList object and processes it until an Action Controller has been matched.

Here is how the FrontController::dispatch() method looks like:

public function dispatch(RequestInterface $request)
{
//before code
while (!$request->isDispatched() && $routingCycleCounter++ < 100) {
/** @var \Magento\Framework\App\RouterInterface $router */
foreach ($this->_routerList as $router) {
           //...
$actionInstance = $router->match($request);
if ($actionInstance) {
$result = $this->processRequest($request, $actionInstance);
break;
}
//...
}
}
//after code
return $result;
}

Behind the scenes, the processRequest() method triggers the execute() method from the Action Controller object.

private function getActionResponse(ActionInterface $actionInstance, RequestInterface $request)
{
//...
if ($actionInstance instanceof AbstractAction) {
return $actionInstance->dispatch($request);
}

return $actionInstance->execute();
}

As we can see, as of Adobe Commerce (Magento) version 2.4.x, there is a backward compatibility for the AbstractAction::dispatch() method.

The $actionInstance->execute() method is the place where your custom Action Controller is triggered. Please note, that there is only support for the execute() method and no other method nor method arguments are supported. It would be cool to have the support for the input method arguments like request and response.

Action Controller

Here is an example of the Action Controller with the execute() method. Oh yes, an Action Controller class must implement one of the Action Interfaces.

<?php declare(strict_types=1);

namespace MageMastery\Lms\Controller\Lesson;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;

class Index implements HttpGetActionInterface
{
/**
* @param ResultFactory $resultFactory
*/
public function __construct(
private readonly ResultFactory $resultFactory
) {}

/**
* @return ResultInterface
*/
public function execute(): ResultInterface
{
       return $this->resultFactory->create(ResultFactory::TYPE_PAGE);
}
}

In the example above, the HttpGetActionInterface interface is implemented by the Index controller.

Creating Routes

Routes can be configured via XML configuration files. There is no other way that Adobe Commerce (Magento) offers out-of-box. The XML, however, works without any issues.

Suppose you want to define your custom dynamic routing like the following /lesson/magento-2-development-workshop/project-setup, where the second part is the course identifier and the third part of the URL is the lesson's identifier. The first part of the URL identifies the type of the page - Lesson. The URL template looks like this /lesson/<course_id>/<lesson_id>.

Action Controller

Create a controller class like the following:

<?php declare(strict_types=1);

namespace MageMastery\Lms\Controller\Lesson;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;

class Index implements HttpGetActionInterface
{
/**
* @param ResultFactory $resultFactory
* @param RequestInterface $request
*/
public function __construct(
private readonly ResultFactory $resultFactory,
private readonly RequestInterface $request,
) {}

/**
* @return ResultInterface
*/
public function execute(): ResultInterface
{
$page = $this->resultFactory->create(ResultFactory::TYPE_PAGE);

$page->getConfig()->getTitle()->set(
'Lesson Page: ' .
$this->request->getParam('course_id') . ' ' .
$this->request->getParam('lesson_id')
);

//load lesson and render it on the page.

return $page;
}
}

The default URL to trigger the above controller is /lms/lesson/index. Custom routing will give us flexibility in using dynamic URLs.

Routing Configuration

The configuration for declaring the Router class, which is responsible for request match logic is the following in the <ModuleName>/etc/frontend/di.xml configuration file:

xml
<type name="Magento\Framework\App\RouterList">
<arguments>
<argument name="routerList" xsi:type="array">
<item name="magemastery_lms_lesson" xsi:type="array">
<item name="class" xsi:type="string">MageMastery\Lms\Controller\Router</item>
<item name="disable" xsi:type="boolean">false</item>
<item name="sortOrder" xsi:type="string">50</item>
</item>
</argument>
</arguments>
</type>

Router

The Router class is responsible for reading the path information from the request object and checking if the request is the right candidate to be processed by the Lesson's Action Controller class.

<?php declare(strict_types=1);

namespace MageMastery\Lms\Controller;

use Magento\Framework\App\ActionFactory;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\Action\Forward;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\App\RouterInterface;

class Router implements RouterInterface
{
/**
* @param ActionFactory $actionFactory
*/
public function __construct(
private readonly ActionFactory $actionFactory
) {}

/**
* @param RequestInterface $request
* @return ActionInterface|void
*/
public function match(RequestInterface $request)
{
$urlIdentifier = trim($request->getPathInfo(), '/');
$explodedUrl = explode('/', $urlIdentifier);

$position = strpos($urlIdentifier, 'lesson');
if ($position === 0 && 3 === count($explodedUrl)) {
$request->setModuleName('lms');
$request->setControllerName('lesson');
$request->setActionName('index');
$request->setParams([
'course_id' => $explodedUrl[1],
'lesson_id' => $explodedUrl[2],
]);

return $this->actionFactory->create(Forward::class);
}
}
}

There are 3 important operations are performed in case the request matches the expectation:

  1. We set the module name, controller name, and action
  2. We set both course_id and lesson_id parameters to the request object
  3. We create the Forward action result so that the Index Action Controller is triggered.

Module Route

The module's route should also be configured in the routes.xml configuration file of the module.

The <Module_Name>/etc/frontend/routes.xml file:

xml
<router id="standard">
<route id="magemastery_lms" frontName="lms">
<module name="MageMastery_Lms" />
</route>
</router>

Creating Additional Router

Let's say you would like to add the route for managing course pages. For this, the URL structure can be like /course/magento-2-development. You would also like to handle this request with the new Course controller. How can we make the change in the Router class?

The first thing that comes to mind is to simply open the Router class and add the required changes.

Let's have a closer look at the possible change in the Router::match() method.

public function match(RequestInterface $request)
{
$urlIdentifier = trim($request->getPathInfo(), '/');
$explodedUrl = explode('/', $urlIdentifier);

$position = strpos($urlIdentifier, 'lesson');
if ($position === 0 && 3 === count($explodedUrl)) {
$request->setModuleName('lms');
$request->setControllerName('lesson');
$request->setActionName('index');
$request->setParams([
'course_id' => $explodedUrl[1],
'lesson_id' => $explodedUrl[2],
]);

return $this->actionFactory->create(Forward::class);
}

$position = strpos($urlIdentifier, 'course');

if ($position === 0 && 2 === count($explodedUrl)) {
$request->setModuleName('lms');
$request->setControllerName('course');
$request->setActionName('index');
$request->setParams([
'course_id' => $explodedUrl[1]
]);

return $this->actionFactory->create(Forward::class);
}
}

In this case, the Router class violates the Single Responsibility Principle, which says that the only reason to change the class or method is when we have to fix a bug. Adding new business logic violates the SRP.

Here I can imagine 2 possible scenarios:

  1. Introduce the new CourseRouter class for course routing and rename the Router class to the LessonRouter class.
  2. Introduce a new set of classes with an RequestMatcherInterface interface and refactor the Router class with further usage of the new dependency.

Both scenarios would work, however, my preference is to introduce a new interface and implement routing logic per class.

Refactoring

Request Matcher

The new interface is going to be responsible for retrieving a boolean value in case the request has been matched and updated.

Create the RequestMatcherInterface interface:

<?php declare(strict_types=1);

namespace MageMastery\Lms\Service\Router;

use Magento\Framework\App\RequestInterface;

interface RequestMatcherInterface
{
/**
* @param RequestInterface $request
* @return bool
*/
public function isMatch(RequestInterface $request): bool;
}

Extract Method to the new Class

The lesson request matcher class can get the logic from the Router and implement the new RequestMatcherInterface interface.

<?php declare(strict_types=1);

namespace MageMastery\Lms\Service\Router;

use Magento\Framework\App\RequestInterface;

class LessonRequestMatcher implements RequestMatcherInterface
{
/**
* @param RequestInterface $request
* @return bool
*/
public function isMatch(RequestInterface $request): bool
{
$urlIdentifier = trim($request->getPathInfo(), '/');
$explodedUrl = explode('/', $urlIdentifier);

$position = strpos($urlIdentifier, 'lesson');
if ($position === 0 && 3 === count($explodedUrl)) {
$request->setModuleName('lms');
$request->setControllerName('lesson');
$request->setActionName('index');
$request->setParams([
'course_id' => $explodedUrl[1],
'lesson_id' => $explodedUrl[2],
]);

return true;
}
return false;
}
}

Refactor Router

The Router class should now depend on the new RequestMatcherInterface interface. When the RequestMatcher::isMatch() returns true, it means that we should prepare the Forward Action object and return it as the result of the Router::match() method.

<?php declare(strict_types=1);

namespace MageMastery\Lms\Controller;

use MageMastery\Lms\Service\Router\RequestMatcherInterface;
use Magento\Framework\App\ActionFactory;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\Action\Forward;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\App\RouterInterface;

class Router implements RouterInterface
{
/**
* @param ActionFactory $actionFactory
* @param RequestMatcherInterface $requestMatcher
*/
public function __construct(
private readonly ActionFactory $actionFactory,
private readonly RequestMatcherInterface $requestMatcher,
) {}

/**
* @param RequestInterface $request
* @return ActionInterface|void
*/
public function match(RequestInterface $request)
{
if ($this->requestMatcher->isMatch($request)) {
return $this->actionFactory->create(Forward::class);
}
}
}

The configuration should be updated for the Router class. Instead of the real Router class name, let's use a configured virtual object where the newly created LessonRequestMatcher is configured.

xml
<type name="Magento\Framework\App\RouterList">
<arguments>
<argument name="routerList" xsi:type="array">
<item name="magemastery_lms_lesson" xsi:type="array">
<item name="class" xsi:type="string">MageMastery\Lms\Controller\LessonRouter</item>
<item name="disable" xsi:type="boolean">false</item>
<item name="sortOrder" xsi:type="string">50</item>
</item>
</argument>
</arguments>
</type>

<virtualType name="MageMastery\Lms\Controller\LessonRouter" type="MageMastery\Lms\Controller\Router">
<arguments>
<argument name="requestMatcher" xsi:type="object">MageMastery\Lms\Service\Router\LessonRequestMatcher</argument>
</arguments>
</virtualType>

The new Router allows adding an unlimited amount of custom URLs that are handled by different action controllers.

Speaking of which, you can now add the new Course related controller and handle custom URLs like /course/magento-2-development. The Single Responsibility Principle is now in tact when it comes to adding new functionality and having no changes in the existing code. Except for the configuration change, that can also be eliminated by introducing a new Adobe Commerce (Magento) module. But we will keep it simple, for now, and have all the logic in a single module.

Additional Request Matcher

The new request matcher class that covers the course URLs looks like the below:

<?php declare(strict_types=1);

namespace MageMastery\Lms\Service\Router;

use Magento\Framework\App\RequestInterface;

class CourseRequestMatcher implements RequestMatcherInterface
{
/**
* @param RequestInterface $request
* @return bool
*/
public function isMatch(RequestInterface $request): bool
{
$urlIdentifier = trim($request->getPathInfo(), '/');
$explodedUrl = explode('/', $urlIdentifier);

$position = strpos($urlIdentifier, 'course');

if ($position === 0 && 2 === count($explodedUrl)) {
$request->setModuleName('lms');
$request->setControllerName('course');
$request->setActionName('index');
$request->setParams([
'course_id' => $explodedUrl[1]
]);

return true;
}

return false;
}
}

The virtual type configuration:

xml
<type name="Magento\Framework\App\RouterList">
<arguments>
<argument name="routerList" xsi:type="array">
<item name="magemastery_lms_course" xsi:type="array">
<item name="class" xsi:type="string">MageMastery\Lms\Controller\CourseRouter</item>
<item name="disable" xsi:type="boolean">false</item>
<item name="sortOrder" xsi:type="string">40</item>
</item>
</argument>
</arguments>
</type>

<virtualType name="MageMastery\Lms\Controller\CourseRouter" type="MageMastery\Lms\Controller\Router">
<arguments>
<argument name="requestMatcher" xsi:type="object">MageMastery\Lms\Service\Router\CourseRequestMatcher</argument>
</arguments>
</virtualType>

Full configuration file with both lesson and course virtual types that are registered in the router list.

<?xml version="1.0"?>
<config>
<type name="Magento\Framework\App\RouterList">
<arguments>
<argument name="routerList" xsi:type="array">
<item name="magemastery_lms_course" xsi:type="array">
<item name="class" xsi:type="string">MageMastery\Lms\Controller\CourseRouter</item>
<item name="disable" xsi:type="boolean">false</item>
<item name="sortOrder" xsi:type="string">40</item>
</item>
<item name="magemastery_lms_lesson" xsi:type="array">
<item name="class" xsi:type="string">MageMastery\Lms\Controller\LessonRouter</item>
<item name="disable" xsi:type="boolean">false</item>
<item name="sortOrder" xsi:type="string">50</item>
</item>
</argument>
</arguments>
</type>

<virtualType name="MageMastery\Lms\Controller\CourseRouter" type="MageMastery\Lms\Controller\Router">
<arguments>
<argument name="requestMatcher" xsi:type="object">MageMastery\Lms\Service\Router\CourseRequestMatcher</argument>
</arguments>
</virtualType>

<virtualType name="MageMastery\Lms\Controller\LessonRouter" type="MageMastery\Lms\Controller\Router">
<arguments>
<argument name="requestMatcher" xsi:type="object">MageMastery\Lms\Service\Router\LessonRequestMatcher</argument>
</arguments>
</virtualType>
</config>

Conclusion

Adobe Commerce (Magento) is a modular platform that provides great opportunities for customization. One of the features that can be used as part of the Adobe Commerce (Magento) module is custom routing. The routing, however, can become a real pain when it comes to future support if implemented incorrectly. For this, the Single Responsibility Principle can help to decide how to add the new custom routing functionality.

In this post, I tried to explain how routing works in Adobe Commerce (Magento), and what the main parts of the request processing flow are. Which include Bootstrap, Application HTTP, Front Controller, Router, and Action Controller. I also showed how the custom router functionality can be refactored in a way that it is possible to add an unlimited amount of custom request matchers classes. I also explain in more detail the creation of the custom Adobe Commerce (Magento) module in my online courses

Feel free to reach out to Pronko Consulting for any e-commerce development work.