A Custom Router for Pretty URLs: /rewards/{slug}
A Custom Router for Pretty URLs: /rewards/{slug}
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Chapter 45 made the history page reachable at the technical URL /mironsoft_loyalty/history/index. No customer should ever see that URL. This chapter builds the custom router that instead serves the configurable customer URLs fixed in the spec: /treuepraemien for the DE store, /rewards for the EN store - including the reward catalog, the history page, and a pretty slug URL per reward.
The blueprint in this project
The real, already-existing Mironsoft\Tutorial module in this project solves exactly the same problem for its series/chapter URLs (Mironsoft\Tutorial\Controller\Router): standard Magento routing only understands the rigid /{frontName}/{controller}/{action} shape and fundamentally cannot produce a URL like /treuepraemien/gold-tier-discount-10. The solution is the same in both cases: a RouterInterface router parses the pathInfo itself, checks that the referenced entity exists, and on success sets the module/controller/action name on the request by hand before triggering a second dispatch pass via a Forward action - the same mechanism Magento\Cms\Controller\Router uses for CMS page URLs.
Front name and history segment as configuration
To keep treuepraemien/rewards configurable per store view - exactly as Mironsoft\Tutorial\Model\Config::getFrontName() already demonstrates for its own module in this project - this chapter extends LoyaltyConfig (chapter 7) with two new, store-scoped values in a new frontend configuration group:
// Addition to Mironsoft\Loyalty\Model\Config\LoyaltyConfig (chapter 7)
public const string XML_PATH_FRONT_NAME = self::SECTION . 'frontend/front_name';
public const string XML_PATH_HISTORY_SLUG = self::SECTION . 'frontend/history_slug';
public function getFrontName(?int $storeId = null): string
{
$frontName = trim((string) $this->scopeConfig->getValue(
self::XML_PATH_FRONT_NAME,
ScopeInterface::SCOPE_STORE,
$storeId
), '/');
return $frontName !== '' ? $frontName : 'treuepraemien';
}
public function getHistorySlug(?int $storeId = null): string
{
$slug = trim((string) $this->scopeConfig->getValue(
self::XML_PATH_HISTORY_SLUG,
ScopeInterface::SCOPE_STORE,
$storeId
), '/');
return $slug !== '' ? $slug : 'verlauf';
}The default in etc/config.xml ships treuepraemien/verlauf for every store; for the EN store (store ID 3), both values get a one-time store-view-level override to rewards/history - exactly the store-scope mechanism chapter 25 already introduced for loyalty_points_multiplier, applied here to plain config values instead of an EAV attribute.
The router class
The router needs to recognize three URL shapes: the empty catalog landing page, the fixed history segment, and a single slug segment that must exist as a reward identifier (chapter 2, a static field on mironsoft_loyalty_reward_entity, not an EAV column):
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Controller;
use Magento\Framework\App\Action\Forward;
use Magento\Framework\App\ActionFactory;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\App\Request\Http as HttpRequest;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\App\RouterInterface;
use Magento\Store\Model\StoreManagerInterface;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Mironsoft\Loyalty\Model\ResourceModel\Reward\CollectionFactory as RewardCollectionFactory;
/**
* Custom frontend router resolving the pretty, store-configurable loyalty URLs:
*
* /rewards -> Controller\Catalog\Index (reward catalog)
* /rewards/history -> Controller\History\Index (points history)
* /rewards/{reward-identifier} -> Controller\Catalog\View (reward detail)
*
* Modelled after this project's own Mironsoft\Tutorial\Controller\Router: existence
* IS checked here (the reward lookup below), because an unconditional match combined
* with Action\Forward re-entering the full router chain would otherwise risk bouncing
* an unresolvable path back and forth until Magento's 100-iteration router safety cap
* is hit. Returning null lets Magento's normal 404 handling take over exactly once.
*/
class Router implements RouterInterface
{
/**
* @param ActionFactory $actionFactory Instantiates the Forward action that re-dispatches to the resolved controller.
* @param LoyaltyConfig $loyaltyConfig Provides the store-configurable front name and history slug.
* @param RewardCollectionFactory $rewardCollectionFactory Confirms a reward identifier exists before claiming the route.
* @param StoreManagerInterface $storeManager Provides the current store ID for the config and existence lookups.
*/
public function __construct(
private readonly ActionFactory $actionFactory,
private readonly LoyaltyConfig $loyaltyConfig,
private readonly RewardCollectionFactory $rewardCollectionFactory,
private readonly StoreManagerInterface $storeManager,
) {
}
/**
* Attempts to resolve the current request to a loyalty controller action.
*
* @param RequestInterface $request Current HTTP request.
* @return ActionInterface|null Null lets the next router in the chain try (and,
* for an unresolvable path, ultimately Magento's normal 404 handling).
*/
public function match(RequestInterface $request): ?ActionInterface
{
if (!$this->loyaltyConfig->isEnabled()) {
return null;
}
$storeId = (int) $this->storeManager->getStore()->getId();
$frontName = $this->loyaltyConfig->getFrontName($storeId);
/** @var HttpRequest $request */
$path = trim((string) $request->getPathInfo(), '/');
if ($path !== $frontName && !str_starts_with($path, $frontName . '/')) {
return null;
}
$rest = trim(substr($path, strlen($frontName)), '/');
$segments = $rest === '' ? [] : explode('/', $rest);
if (count($segments) === 0) {
return $this->forwardTo($request, 'catalog', 'index', []);
}
if (count($segments) > 1) {
return null;
}
if ($segments[0] === $this->loyaltyConfig->getHistorySlug($storeId)) {
return $this->forwardTo($request, 'history', 'index', []);
}
$reward = $this->rewardCollectionFactory->create()
->addActiveFilter()
->addFieldToFilter('identifier', ['eq' => $segments[0]])
->getFirstItem();
if (!$reward->getId()) {
return null;
}
return $this->forwardTo($request, 'catalog', 'view', ['reward_identifier' => $segments[0]]);
}
/**
* Sets the resolved module/controller/action/params on the request and returns a
* Forward action to re-dispatch through the standard controller resolution mechanism.
*
* @param RequestInterface $request Current HTTP request, mutated in place.
* @param string $controllerName Target controller directory (snake_case maps to StudlyCase).
* @param string $actionName Target action file (snake_case maps to StudlyCase).
* @param array<string, string> $params Route parameters made available via getParam().
* @return ActionInterface
*/
private function forwardTo(RequestInterface $request, string $controllerName, string $actionName, array $params): ActionInterface
{
/** @var HttpRequest $request */
$request->setModuleName('mironsoft_loyalty')
->setControllerName($controllerName)
->setActionName($actionName)
->setParams($params);
return $this->actionFactory->create(Forward::class);
}
}Registering in the RouterList
<?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">
<!-- sortOrder=40: runs before Magento_Cms's router (60), so a
reward slug never gets mistaken for a CMS page URL, and after
the core "standard" router (20) so the internal
/mironsoft_loyalty/... path from chapter 45 still resolves
directly if it's ever hit outright. -->
<item name="mironsoft_loyalty" xsi:type="array">
<item name="class" xsi:type="string">Mironsoft\Loyalty\Controller\Router</item>
<item name="disable" xsi:type="boolean">false</item>
<item name="sortOrder" xsi:type="string">40</item>
</item>
</argument>
</arguments>
</type>
</config>Achtung: Router collisions are the classic pitfall with this pattern: if a reward ever had the identifier history, it could never be reached, because match() always checks the fixed history segment first. That's why the reward validation from chapter 17 additionally checks that identifier doesn't collide with the currently configured history_slug - a pure data quality rule, not a router change. The other way round: a router that blindly returns a forward action for any single-segment path without checking that the reward exists would swallow every 404-capable path under /rewards/* (see the docblock reasoning above) - the explicit getFirstItem() check is therefore not an afterthought.
Tipp: Don't forget bin/magento cache:flush config after every change to front_name/history_slug - ScopeConfigInterface values are cached, so an old front name would otherwise keep working (just no longer visible in the admin) until the config cache is cleared.