Eigener Router für hübsche URLs: /treuepraemien/{slug}
Eigener Router für hübsche URLs: /treuepraemien/{slug}
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Kapitel 45 hat die Historie-Seite unter der technischen URL /mironsoft_loyalty/history/index erreichbar gemacht. Kein Kunde soll diese URL je zu Gesicht bekommen. Dieses Kapitel baut den eigenen Router, der stattdessen die im Spec festgelegten, konfigurierbaren Kunden-URLs bedient: /treuepraemien für den DE-Store, /rewards für den EN-Store - inklusive Prämienkatalog, Historie-Seite und einer hübschen Slug-URL pro Prämie.
Das Vorbild in diesem Projekt
Das echte, bereits im mironsoft-Projekt existierende Modul Mironsoft\Tutorial löst exakt dasselbe Problem für seine Serien-/Kapitel-URLs (Mironsoft\Tutorial\Controller\Router): Standard-Magento-Routing kennt nur das starre Schema /{frontName}/{controller}/{action} und kann URLs wie /treuepraemien/goldstatus-rabatt-10 grundsätzlich nicht produzieren. Die Lösung ist in beiden Fällen dieselbe: Ein RouterInterface-Router parst den pathInfo selbst, prüft die Existenz der referenzierten Entität und setzt bei Erfolg Modul-/Controller-/Actionname manuell auf dem Request, bevor er per Forward-Action einen zweiten Dispatch-Durchlauf auslöst - denselben Mechanismus, den auch Magento\Cms\Controller\Router für CMS-Seiten-URLs nutzt.
Front Name und Verlauf-Segment als Konfiguration
Damit treuepraemien/rewards pro Store-View konfigurierbar bleiben - genau wie es Mironsoft\Tutorial\Model\Config::getFrontName() in diesem Projekt bereits für sein eigenes Modul vormacht - erweitert dieses Kapitel LoyaltyConfig (Kapitel 7) um zwei neue, store-scope-fähige Werte in einer neuen Konfigurationsgruppe frontend:
// Ergänzung in Mironsoft\Loyalty\Model\Config\LoyaltyConfig (Kapitel 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';
}Der Default in etc/config.xml liefert treuepraemien/verlauf für alle Stores; für den EN-Store (Store-ID 3) werden beide Werte einmalig auf Store-View-Ebene auf rewards/history überschrieben - genau der Store-Scope-Mechanismus, den Kapitel 25 bereits für loyalty_points_multiplier eingeführt hat, hier angewendet auf normale Konfigurationswerte statt ein EAV-Attribut.
Die Router-Klasse
Drei URL-Formen soll der Router erkennen: die leere Katalog-Startseite, das feste Verlauf-Segment und ein einzelnes Slug-Segment, das als Prämien-identifier (Kapitel 2, statisches Feld auf mironsoft_loyalty_reward_entity, keine EAV-Spalte) existieren muss:
<?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:
*
* /treuepraemien -> Controller\Catalog\Index (reward catalog)
* /treuepraemien/verlauf -> Controller\History\Index (points history)
* /treuepraemien/{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);
}
}Registrierung in der 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-Kollisionen sind der klassische Stolperstein bei diesem Muster: Würde eine Prämie jemals den identifier verlauf tragen, könnte sie nie erreicht werden, weil match() das feste Verlauf-Segment IMMER zuerst prüft. Deshalb prüft die Prämien-Validierung aus Kapitel 17 zusätzlich, dass identifier nicht mit dem aktuell konfigurierten history_slug kollidiert - eine reine Datenqualitätsregel, keine Router-Änderung. Umgekehrt: Ein Router, der bei jedem beliebigen Ein-Segment-Pfad blind eine Forward-Action zurückgibt, ohne die Existenz der Prämie zu prüfen, würde jeden 404-fähigen Pfad unter /treuepraemien/* canceln (siehe die Docblock-Begründung oben) - der explizite getFirstItem()-Check ist deshalb keine Nebensächlichkeit.
Tipp: bin/magento cache:flush config nach jeder Änderung an front_name/history_slug nicht vergessen - ScopeConfigInterface-Werte werden gecacht, ein alter Front Name würde sonst weiter funktionieren (nur eben nicht mehr im Admin sichtbar), bis der Config-Cache geleert wird.