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

Custom Controller Structure: The Points History Page

Custom Controller Structure: The Points History Page

~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

Blocks 1 through 5 built a complete backend: data model, EAV entity, custom attributes, observers, cron, plugins, and preferences. None of it was visible so far - not a single storefront page has touched any of it. Block 6 changes that: chapters 45 through 54 build controllers, a custom router, and the first templates that let a customer actually see their points balance. This chapter starts with the simplest new page - the points history - and in doing so lays out the complete controller directory structure for the whole block.

This module's controller directory

Four controller actions are built in block 6, plus the custom router from chapter 46 directly above them in the Controller/ namespace (Magento expects router classes directly there, not in a subfolder, unlike controller actions):

New directories and files from block 6 (extends the structure from blocks 1-5)

app/code/Mironsoft/Loyalty/
├── Controller/
│   ├── Router.php                  # chapter 46
│   ├── History/
│   │   └── Index.php               # this chapter
│   ├── Catalog/
│   │   ├── Index.php               # chapter 49 - reward list
│   │   └── View.php                # chapter 49 - reward detail
│   └── Redeem/
│       └── Index.php               # chapter 50 - redeem a reward (POST)
└── etc/
    └── frontend/
        ├── routes.xml               # chapter 46
        └── di.xml                   # chapter 46 - RouterList entry

AccountInterface instead of a manual login check

The points history is a pure "My Account" page - a logged-out visitor must never see it. Instead of manually checking CustomerSession::isLoggedIn() in every action and redirecting to the login page by hand, it's enough to implement the empty marker interface Magento\Customer\Controller\AccountInterface. The core already registers a global around plugin for it (Magento\Customer\Controller\Plugin\Account, wired in vendor/magento/module-customer/etc/frontend/di.xml directly onto the interface itself) - any controller class implementing AccountInterface gets intercepted automatically: $session->authenticate() redirects logged-out visitors to the login page before execute() ever runs. This is exactly the interface every core controller under My Account uses, from the order overview to address management.

app/code/Mironsoft/Loyalty/Controller/History/Index.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Controller\History;

use Magento\Customer\Controller\AccountInterface;
use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;

/**
 * Renders the customer-facing points history page. Implementing AccountInterface
 * (a marker interface with no methods of its own, see
 * Magento\Customer\Controller\AccountInterface) makes Magento's own
 * Magento\Customer\Controller\Plugin\Account plugin force a login redirect before
 * execute() ever runs - no manual session check needed here.
 */
class Index extends Action implements HttpGetActionInterface, AccountInterface
{
    /**
     * @param Context $context Framework action context (request/response/redirect helpers).
     * @param PageFactory $resultPageFactory Builds the full page result handed back to the front controller.
     */
    public function __construct(
        Context $context,
        private readonly PageFactory $resultPageFactory,
    ) {
        parent::__construct($context);
    }

    /**
     * Builds the points history page. The ledger rows themselves are not fetched
     * here - the view model wired into the layout (chapter 51) reads
     * PointsLedgerRepositoryInterface::getListByCustomerId() (chapter 6) on its own,
     * so this controller stays a thin dispatcher per the block/ViewModel split
     * explained in chapter 47.
     *
     * @return Page
     */
    public function execute(): Page
    {
        $resultPage = $this->resultPageFactory->create();
        $resultPage->getConfig()->getTitle()->set((string) __('My Points History'));

        return $resultPage;
    }
}

The internal route in routes.xml

For this controller to be reachable over HTTP at all, the module needs a route. As is already common practice in this project's own, real Mironsoft\Tutorial module, the frontName registered here is a purely technical, internal value - never the actually visible URL. The pretty, configurable customer-facing URL (/treuepraemien/... or /rewards/...) is only added in chapter 46, via a custom router.

app/code/Mironsoft/Loyalty/etc/frontend/routes.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <!--
        This frontName is an internal routing token only, never exposed to visitors.
        Mironsoft\Loyalty\Controller\Router (chapter 46) intercepts the real,
        store-configurable /treuepraemien or /rewards path first and explicitly sets
        this module name on the request before forwarding, so the standard core router
        can resolve the actual controller class on a second dispatch pass - the same
        two-pass mechanism Magento\Cms\Controller\Router and this project's own
        Mironsoft\Tutorial\Controller\Router use.
    -->
    <router id="standard">
        <route id="mironsoft_loyalty" frontName="mironsoft_loyalty">
            <module name="Mironsoft_Loyalty"/>
        </route>
    </router>
</config>

Tipp: With just this routes.xml file, the history page is already reachable under the technical, unpretty URL /mironsoft_loyalty/history/index - Magento's standard routing scheme /{frontName}/{controller}/{action} kicks in as usual. That's exactly what you'd verify briefly in a browser at the end of this chapter, before chapter 46 adds the pretty URL.

No ACL resource for frontend controllers

Achtung: The reward admin grid controller from chapter 16 (Controller\Adminhtml\Reward\Index) absolutely needed const ADMIN_RESOURCE = 'Mironsoft_Loyalty::rewards' and a matching acl.xml resource, or every admin user would have had access. Frontend controllers like this one don't have that concept at all - access control runs exclusively through AccountInterface (logged in or not) rather than granular permissions. Adding an ADMIN_RESOURCE constant to a frontend controller would simply do nothing.

Tipp: Chapter 46 now builds the actual router that turns /mironsoft_loyalty/history/index into the configurable, store-dependent URL /treuepraemien/verlauf (DE) or /rewards/history (EN) - along with the reward catalog landing page and the per-reward detail page.