Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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:

app/code/Mironsoft/Loyalty/Controller/Router.php
<?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

app/code/Mironsoft/Loyalty/etc/frontend/di.xml
<?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.