Routing, Controllers, Blocks, Layouts and Templates

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Magento Open Source and Adobe Commerce 2.4.9 (general availability 2026-05-12) and checked on 2026-10-27. Every example was installed and requested on Mage-OS 3.6.0, the Open Source distribution based on Magento 2.4.9, with the Luma theme, so the URLs, response codes and HTML quoted below come from a running store. The material applies to the in-process PHP storefront of Magento Open Source and of Adobe Commerce on-premises and on cloud infrastructure. Adobe Commerce as a Cloud Service (ACCS) has no PHP modules and no Luma storefront (its storefront is the Commerce Storefront on Edge Delivery Services), so nothing here applies to that edition. Hyvä and other alternative themes keep routes, controllers, layout XML and view models, but replace the templates and the JavaScript, as described in Frontend: Hyvä, PWA Studio and Edge Delivery.

The storefront request path has five parts that you will touch in almost every module: a route that maps a URL to a module, a controller action that does the work and returns a result, a layout (XML) that lists the blocks of the page, blocks and view models that supply data, and templates (.phtml files) that print the HTML. The running example is the Pocket Coffee roast page of the module PocketCoffee_RoastPage: a "Fresh roasts" page at /roast/ (also reachable as /coffee-club) that lists products with their roast date, a coffee club sign-up form, a JSON endpoint for one batch, a roast badge on the product page and a footer link. It reads the pc_roast_date and pc_origin attributes created in EAV and Attributes. The module skeleton and module:enable flow are in Module Anatomy, and the Admin counterpart of routes and controllers is in Admin Development.

From URL to HTML

From a storefront URL to a rendered template: routers, action class, layout handles, blocks and phtml

Magento\Framework\App\FrontController asks each router of the area, in sortOrder, to match the request. The first router that returns an action class wins; the action is executed and returns a result object, and for a page result the layout is built and rendered. The routers registered in the frontend area of the test installation were read from the etc/frontend/di.xml files of the modules:

Sort order Router What it matches

10

robots, securitytxt

/robots.txt and the security.txt file (the second one is part of Mage-OS 3.6.0)

20

urlrewrite

Any request path stored in the url_rewrite table: product, category and CMS URLs and custom redirects

25

pocketcoffee_roast_friendly

The custom router of this page, for /coffee-club

30

standard

The frontName/controller/action form declared by routes.xml

60

cms

CMS pages by identifier

100

default

Anything left; it runs the no-route handler and produces the 404 page

The adminhtml area has the admin router (sort order 10, Magento\Backend\App\Router) and the default router (100) in module-backend. The standard order of 30 is the value from the Adobe documentation; the other values were read from the test installation.

routes.xml: frontName and route id

A module declares its URL space in etc/frontend/routes.xml (storefront) or etc/adminhtml/routes.xml (Admin). The file location decides the area. It is validated by urn:magento:framework:App/etc/routes.xsd.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="pcroast" frontName="roast">
            <module name="PocketCoffee_RoastPage"/>
        </route>
    </router>
</config>

Two identifiers look alike and mean different things:

Attribute Meaning

router id

The router that handles the route: standard for storefront modules, admin for the Admin. Not a free choice.

route id (pcroast)

The unique internal name of the route. It is the first segment of the layout handle, the prefix of the Admin ACL resource of some controllers and the key used by URL generation internals.

frontName (roast)

The first segment of the public URL. It is what customers and search engines see; it can equal the route id, but does not have to, and two modules must not claim the same frontName unless one uses before or after.

module name

The module whose Controller directory holds the actions. A route can list several modules, with before="Vendor_Module" or after="Vendor_Module", to place its actions before or after the actions of another module on the same route.

With these declarations the standard router builds the URL as <base-url>/<store-code>/<frontName>/<controller>/<action>, where the store code appears only if "Add Store Code to URLs" is enabled. /roast/ is the same as /roast/index/index: a missing controller or action defaults to index. The controller segment is the sub-directory under Controller/ (Index, Batch, Webhook) and the action segment the class name (Index.php, Subscribe.php, Receive.php); both are lower-cased in the URL, and a class RoastBatch becomes roastbatch, not roast-batch.

The frontName was tested against the layout handle during the run for this page, with a deliberately common mistake: the first version of the page named its layout file roast_index_index.xml after the frontName. The page returned HTTP 200 with the title "Fresh roasts" but none of the blocks, because the handle is built from the route id, and the log held no error. Renaming the file to pcroast_index_index.xml made the blocks appear. When a controller works but the layout does nothing, check the handle first.

before and after: overriding a route

The before attribute puts your module ahead of an existing one on the same route, so that its action classes are found first:

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="customer">
            <module name="PocketCoffee_RoastPage" before="Magento_Customer"/>
        </route>
    </router>
</config>

With this declaration a class Controller/Account/Login.php in the module would answer customer/account/login instead of the core class. It is an escape hatch with the same cost as a preference: it replaces behaviour for every extension, so prefer a plugin on the core action or an event. The example was not run, as it would change a core route of the shared installation.

Action classes

An action class is the controller of Magento 2: one class per URL, with one public method execute(). It implements an HTTP-method interface to declare the verbs it accepts. The most common interfaces are HttpGetActionInterface, HttpPostActionInterface, HttpPutActionInterface and HttpDeleteActionInterface in Magento\Framework\App\Action. The framework rejects a request whose verb does not match, and applies the built-in CSRF check to the non-GET verbs.

A page action

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastPage\Controller\Index;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;

/**
 * GET /roast/ and GET /coffee-club: the layout handle is pcroast_index_index (route id, not frontName).
 */
class Index implements HttpGetActionInterface
{
    public function __construct(private readonly PageFactory $resultPageFactory)
    {
    }

    public function execute(): Page
    {
        $page = $this->resultPageFactory->create();
        $page->getConfig()->getTitle()->set(__('Fresh roasts'));

        return $page;
    }
}

The action takes the PageFactory through the constructor, creates the page result and sets the title through the page configuration. It never touches the ObjectManager, a response object or echo. Because it implements only HttpGetActionInterface, any other verb gets "404 Not Found": the test installation returned 404 for POST /roast/batch/index and for PUT /roast/batch/index against the GET-only JSON action and 404 for GET /roast/index/subscribe/ against the POST-only action.

Result objects

An action returns a result object, created by the matching factory. The table lists the result types of the Adobe documentation.

Result Behaviour

json (JsonFactory)

Sets Content-Type: application/json and encodes an array.

raw (RawFactory)

Returns the data as it was set, without setting a content type.

redirect (RedirectFactory)

An external redirect; the browser requests a new URL (setPath('roast/index/index') builds the URL for a route path).

forward (ForwardFactory)

Calls another action internally; the URL does not change and the browser does not make a new request.

layout (LayoutFactory)

A generic layout result, for rendering any layout without the page wrapper (for example an AJAX fragment).

page (PageFactory)

A full page: page type, page configuration and the default layout handles; the layout XML is rendered into HTML.

A POST action with messages

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastPage\Controller\Index;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Redirect;
use Magento\Framework\Controller\Result\RedirectFactory;
use Magento\Framework\Message\ManagerInterface;
use Magento\Framework\Validator\EmailAddress;
use Psr\Log\LoggerInterface;

/**
 * POST /roast/index/subscribe: records an interest in the coffee club. The form key is checked by the framework
 * because the class implements HttpPostActionInterface.
 */
class Subscribe implements HttpPostActionInterface
{
    public function __construct(
        private readonly RequestInterface $request,
        private readonly RedirectFactory $redirectFactory,
        private readonly ManagerInterface $messageManager,
        private readonly EmailAddress $emailValidator,
        private readonly LoggerInterface $logger
    ) {
    }

    public function execute(): Redirect
    {
        $email = trim((string) $this->request->getPostValue('email'));
        $redirect = $this->redirectFactory->create()->setPath('roast/index/index');

        if (!$this->emailValidator->isValid($email)) {
            $this->messageManager->addErrorMessage(__('Please enter a valid email address.'));

            return $redirect;
        }

        $this->logger->info('PocketCoffee_RoastPage: coffee club interest', ['email' => $email]);
        $this->messageManager->addSuccessMessage(__('Thank you, we will write to %1.', $email));

        return $redirect;
    }
}

The action reads the post value through the injected request, validates the email with the framework validator, writes a log entry and redirects back to the page with a success or an error message. The message manager stores the message in the mage-messages cookie, which the Luma messages component displays after the redirect. Each of the three outcomes was requested against the test installation:

Request Result Why

POST /roast/index/subscribe/ with a valid form key and grace@example.test

302 to /roast/index/index/; the log line coffee club interest was written; the cookie held the success message

Normal path.

The same request with the email bad

302 to /roast/index/index/

Validator failed, error message.

The same request with a wrong form key

302 to the home page; the mage-messages cookie held "Invalid Form Key. Please refresh the page."

The CSRF check redirects before the action runs.

GET /roast/index/subscribe/

404

The action accepts POST only.

Form keys with the full page cache

The form key is validated against the session. The storefront pages are cached in the full page cache (FPC) for every visitor, so the form key is not baked into the HTML: the Magento_PageCache module generates it in the browser, stores it in the form_key cookie and fills the hidden form_key input of every form (through lib/web/mage/common.js). On the server the plugin RegisterFormKeyFromCookie copies the cookie value into the session before the action is dispatched. A script or an integration test that posts to a storefront action without the browser’s JavaScript must therefore send the same value twice, as the cookie form_key and as the post parameter form_key. That is how the requests above were issued with curl:

K=abcdEFGH12345678   # any 16-character alphanumeric string
curl -s -X POST localhost:8082/roast/index/subscribe/ \
  -b "form_key=$K" -d "form_key=$K&email=grace@example.test" \
  -o /dev/null -w "%{http_code} -> %{redirect_url}\n"
# 302 -> http://localhost:8082/roast/index/index/

The Magento\PageCache\Plugin\RegisterFormKeyFromCookie plugin does that copy. The header X-Requested-With: XMLHttpRequest does not exempt a request: an AJAX post with a wrong key was refused as well (HTTP 302 on the test installation).

Opting out of the CSRF check

A controller that receives calls from another system cannot carry a form key. It implements CsrfAwareActionInterface, whose two methods return the exception to throw and whether the request is valid. Returning true from validateForCsrf() skips the check, and the controller then has to authenticate the caller itself.

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastPage\Controller\Webhook;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\Result\JsonFactory;

/**
 * POST /roast/webhook/receive: called by a roaster's system, so there is no form key. It opts out of the
 * built-in CSRF check and must authenticate the caller some other way (here: a shared secret header).
 */
class Receive implements HttpPostActionInterface, CsrfAwareActionInterface
{
    public function __construct(
        private readonly RequestInterface $request,
        private readonly JsonFactory $jsonFactory
    ) {
    }

    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null;
    }

    public function validateForCsrf(RequestInterface $request): ?bool
    {
        return true;
    }

    public function execute(): Json
    {
        $result = $this->jsonFactory->create();
        // A real handler compares the header with a secret stored in encrypted configuration.
        if ($this->request->getHeader('X-Roaster-Token') !== 'replace-with-a-configured-secret') {
            return $result->setHttpResponseCode(403)->setData(['error' => 'Forbidden']);
        }

        return $result->setData(['received' => true]);
    }
}

The test installation answered 403 {"error":"Forbidden"} for a wrong token and 200 {"received":true} for the expected value. The literal in the sample stands for a secret that a real controller reads from encrypted configuration (see Admin Development) and compares in constant time with hash_equals().

A JSON action

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastPage\Controller\Batch;

use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\Webapi\Exception as WebapiException;
use PocketCoffee\Eav\Setup\Patch\Data\AddProductAttributes;
use PocketCoffee\RoastPage\Model\RoastBatches;

/**
 * GET /roast/batch/index?sku=PC-ETH-WHL-250 returns the roast data as JSON.
 */
class Index implements HttpGetActionInterface
{
    public function __construct(
        private readonly RequestInterface $request,
        private readonly JsonFactory $jsonFactory,
        private readonly RoastBatches $roastBatches
    ) {
    }

    public function execute(): Json
    {
        $result = $this->jsonFactory->create();
        $product = $this->roastBatches->getBySku((string) $this->request->getParam('sku'));

        if ($product === null) {
            return $result->setHttpResponseCode(WebapiException::HTTP_NOT_FOUND)
                ->setData(['error' => 'Unknown SKU']);
        }

        $date = $product->getCustomAttribute(AddProductAttributes::ROAST_DATE);

        return $result->setData([
            'sku' => $product->getSku(),
            'roast_date' => $date ? substr((string) $date->getValue(), 0, 10) : null,
        ]);
    }
}

GET /roast/batch/index?sku=PC-ETH-WHL-250 returned {"sku":"PC-ETH-WHL-250","roast_date":"2026-10-20"} with the content type application/json, and an unknown SKU returned 404 with {"error":"Unknown SKU"}. Storefront JSON endpoints like this one are meant for the storefront’s own AJAX, not for integrations: an integration should use the REST API described in REST and GraphQL APIs.

A custom router

The standard router needs a frontName. To serve /coffee-club without a url_rewrite row, implement RouterInterface and register it in the routerList of the frontend area:

<?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">
                <item name="pocketcoffee_roast_friendly" xsi:type="array">
                    <item name="class" xsi:type="string">PocketCoffee\RoastPage\Controller\Router</item>
                    <item name="disable" xsi:type="boolean">false</item>
                    <item name="sortOrder" xsi:type="string">25</item>
                </item>
            </argument>
        </arguments>
    </type>
</config>
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastPage\Controller;

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

/**
 * Maps the friendly URL /coffee-club to the roast page without a URL rewrite row.
 */
class Router implements RouterInterface
{
    public function __construct(private readonly ActionFactory $actionFactory)
    {
    }

    public function match(RequestInterface $request): ?ActionInterface
    {
        if (trim($request->getPathInfo(), '/') !== 'coffee-club') {
            return null;
        }

        // Rewrite the path so that this router does not match again on the next loop of the front controller,
        // and keep the requested path as the alias that the rest of the system can read.
        $request->setAlias(UrlInterface::REWRITE_REQUEST_PATH_ALIAS, 'coffee-club');
        $request->setPathInfo('/roast/index/index');
        $request->setModuleName('roast')->setControllerName('index')->setActionName('index');

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

The router returns null when it does not recognise the path, so that the next router is asked, and returns a Forward action when it does. The first version of this router did not change the path and the request failed with LogicException: Front controller reached 100 router match iterations (HTTP 500): a forwarded request is dispatched again, the same router matched again, and the front controller gave up after 100 loops. Rewriting the path info to the target path, as in the code above, ends the loop; the original path is kept as the REWRITE_REQUEST_PATH_ALIAS alias. After the change GET /coffee-club returned the same page as /roast/. For ordinary pretty URLs, a row in url_rewrite (Marketing > SEO & Search > URL Rewrites) or a CMS page is simpler than a router.

Blocks

A block is a PHP class that prepares data for a template, created from a <block> instruction of a layout file. Most blocks extend Magento\Framework\View\Element\Template, which provides getTemplate(), getChildHtml(), getBlockHtml(), getUrl() and $escaper for the template. A block needs no class of its own: a layout can instantiate the stock Template class, or a core class such as Magento\Catalog\Block\Product\View, which offers getProduct().

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastPage\Block;

use Magento\Framework\View\Element\Template;

/**
 * A thin block: it only supplies the heading, everything else comes from the view model argument.
 */
class RoastSummary extends Template
{
    public function getHeading(): string
    {
        return (string) __('Fresh from the roaster');
    }
}

Block classes are the legacy place for logic. They are hard to unit-test (a large constructor with the template context), they cannot be reused across templates, and subclassing them for each template is a recurring source of upgrade breakage. The Adobe documentation therefore recommends view models for new code: ordinary classes that implement Magento\Framework\View\Element\Block\ArgumentInterface, passed to the block as an argument. They exist since 2.2, and the same documentation says that helpers in templates are discouraged.

A view model

<?php

declare(strict_types=1);

namespace PocketCoffee\RoastPage\ViewModel;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
use Magento\Framework\UrlInterface;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use PocketCoffee\Eav\Setup\Patch\Data\AddProductAttributes;
use PocketCoffee\RoastPage\Model\RoastBatches;

/**
 * Data and URLs for the roast page and the product badge. It holds the logic so the templates only print.
 */
class RoastList implements ArgumentInterface
{
    public function __construct(
        private readonly RoastBatches $roastBatches,
        private readonly RequestInterface $request,
        private readonly TimezoneInterface $timezone,
        private readonly UrlInterface $urlBuilder
    ) {
    }

    public function getOriginFilter(): string
    {
        return (string) $this->request->getParam('origin', '');
    }

    /**
     * @return ProductInterface[]
     */
    public function getProducts(): array
    {
        $filter = $this->getOriginFilter();

        return $this->roastBatches->getProducts(ctype_digit($filter) ? $filter : null);
    }

    public function formatRoastDate(ProductInterface $product): string
    {
        $attribute = $product->getCustomAttribute(AddProductAttributes::ROAST_DATE);
        if ($attribute === null || !$attribute->getValue()) {
            return '';
        }

        return $this->timezone->formatDate((string) $attribute->getValue(), \IntlDateFormatter::MEDIUM);
    }

    public function getBadgeText(ProductInterface $product): string
    {
        $date = $this->formatRoastDate($product);

        return $date === '' ? '' : (string) __('Roasted on %1', $date);
    }

    public function getSubscribeUrl(): string
    {
        return $this->urlBuilder->getUrl('roast/index/subscribe');
    }

    public function getBatchUrl(string $sku): string
    {
        return $this->urlBuilder->getUrl('roast/batch/index', ['_query' => ['sku' => $sku]]);
    }
}
<?php

declare(strict_types=1);

namespace PocketCoffee\RoastPage\Model;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use PocketCoffee\Eav\Setup\Patch\Data\AddProductAttributes;

/**
 * Reads the products that carry a roast date, optionally narrowed to one origin option id.
 */
class RoastBatches
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder
    ) {
    }

    /**
     * @return ProductInterface[]
     */
    public function getProducts(?string $originId = null): array
    {
        $this->searchCriteriaBuilder->addFilter(AddProductAttributes::ROAST_DATE, true, 'notnull');
        if ($originId !== null && $originId !== '') {
            $this->searchCriteriaBuilder->addFilter(AddProductAttributes::ORIGIN, $originId);
        }
        $this->searchCriteriaBuilder->setPageSize(50);

        return array_values($this->productRepository->getList($this->searchCriteriaBuilder->create())->getItems());
    }

    public function getBySku(string $sku): ?ProductInterface
    {
        try {
            return $this->productRepository->get($sku);
        } catch (\Magento\Framework\Exception\NoSuchEntityException) {
            return null;
        }
    }
}

The view model carries every decision: which products to show, how to format the date, which URL to link to. It uses the product repository with a SearchCriteria filter on the EAV attributes (see Service Contracts and Repositories), the timezone service for the store’s date format and the URL builder for the links. The layout instantiates it through the object manager, so its constructor dependencies are injected like those of any service. It is shared by the page and by the product badge, because the same class is configured as an argument of two blocks. The ArgumentInterface marker is required: without it the argument is rejected when the layout is built.

Layout XML

A layout file declares the structure of a page: which containers exist, which blocks fill them, which template each block uses and which arguments it receives. Files are merged from all modules and the theme, so a module can add to any page without editing it.

File names and handles

A layout handle is the file name without the extension. The merged result for a request is built from a list of handles:

  • default is added to every page.

  • <routeId>_<controller>_<action>, here pcroast_index_index, is added for the controller that handled the request. The route id comes from routes.xml, not the frontName.

  • Handles added by the result or the layout itself: catalog_product_view for a product page, catalog_category_view for a category, cms_page_view for a CMS page, a customer_logged_in or customer_logged_out handle, and handles that other files include with <update handle="..."/>.

  • The page layout (1column, 2columns-left, 2columns-right, 3columns, empty) is a separate file under page_layout/ that defines the wireframe; the layout attribute of the <page> element selects it.

Layout files of a module live under view/frontend/layout/ (storefront) or view/adminhtml/layout/ (Admin), and view/base/layout/ is loaded in both areas. A theme extends a module’s layout by adding a file with the same name in <theme_dir>/<Vendor_Module>/layout/, which is merged after the module file; it replaces the module file completely if it is placed in layout/override/base/ (replaces the module’s file) or layout/override/theme/<Vendor>/<parent>/ (replaces the parent theme’s file).

The page layout file of the example

<?xml version="1.0"?>
<page layout="1column" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <head>
        <css src="PocketCoffee_RoastPage::css/roast.css"/>
    </head>
    <body>
        <referenceContainer name="page.top">
            <container name="pocketcoffee.roast.banner" htmlTag="div" htmlClass="pocketcoffee-roast-banner">
                <block class="Magento\Framework\View\Element\Text" name="pocketcoffee.roast.banner.text">
                    <action method="setText">
                        <argument name="text" xsi:type="string" translate="true">Roasted every Tuesday</argument>
                    </action>
                </block>
            </container>
        </referenceContainer>
        <move element="page.main.title" destination="content" before="-"/>
        <referenceBlock name="breadcrumbs" remove="true"/>
        <referenceContainer name="content">
            <block class="PocketCoffee\RoastPage\Block\RoastSummary" name="pocketcoffee.roast.summary" template="PocketCoffee_RoastPage::roast/list.phtml">
                <arguments>
                    <argument name="view_model" xsi:type="object">PocketCoffee\RoastPage\ViewModel\RoastList</argument>
                </arguments>
                <block class="Magento\Framework\View\Element\Template" name="pocketcoffee.roast.subscribe" as="subscribe" template="PocketCoffee_RoastPage::roast/subscribe.phtml">
                    <arguments>
                        <argument name="view_model" xsi:type="object">PocketCoffee\RoastPage\ViewModel\RoastList</argument>
                    </arguments>
                </block>
            </block>
        </referenceContainer>
    </body>
</page>

The file uses these instructions:

Instruction What it does

<page layout="1column">

Root of a page configuration file; the optional layout attribute picks the page layout.

<head><css src="..."/></head>

Adds a static asset to the page head (css, script, title, meta, link, font). Vendor_Module::css/roast.css resolves to view/frontend/web/css/roast.css. <remove src="..."/> in the head removes an asset that another file added.

<referenceContainer name="...">, <referenceBlock name="...">

Addresses an existing container or block to add children, change arguments or remove it. The remove="true" attribute removes it: <referenceBlock name="breadcrumbs" remove="true"/> removed the breadcrumbs from the page; the test response contained no breadcrumbs markup afterwards.

<container name="..." htmlTag="div" htmlClass="...">

A wrapper without a template; it renders its children in order and optionally a tag around them. The banner of the example rendered as <div class="pocketcoffee-roast-banner">Roasted every Tuesday</div>.

<block class="..." name="..." template="Vendor_Module::path.phtml">

A block instance. class defaults to Magento\Framework\View\Element\Template; name must be unique per page (an unnamed block gets an ANONYMOUS_n name, and a block without a name cannot be made non-cacheable); as is the alias that getChildHtml('alias') uses; before and after order siblings (- means first or last); cacheable="false" makes the page uncacheable; ifconfig="section/group/field" hides the block unless the configuration flag is set; display="false" keeps the class loaded but does not render it.

<arguments><argument name="..." xsi:type="...">

Passes data to the block, readable as $block->getData('name') or with the magic getter. Types include string, boolean, number, object (a class instantiated by the object manager, used for view models), array with item children and helper. translate="true" marks a string for translation.

<action method="setText">

Calls a method of the block with arguments. Supported for compatibility; prefer arguments.

<move element="page.main.title" destination="content" before="-"/>

Moves an element to another parent. The example moved the page title into the content column, and the page title block moved from before to inside div.columns.

<update handle="..."/>

Includes another handle’s instructions.

<remove> removes only static assets from the head; use the remove attribute of referenceBlock or referenceContainer for blocks. A reference to an element that does not exist does not fail: it writes a Broken reference line to var/log/system.log, as the log of the test installation showed for several core sidebar elements that the Luma theme does not define, so grep the log after a layout change.

Extending existing pages

The product page badge adds a block to the existing container product.info.main of catalog_product_view, after the price container. It uses the core block class Magento\Catalog\Block\Product\View only because that class provides getProduct().

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="product.info.main">
            <block class="Magento\Catalog\Block\Product\View" name="pocketcoffee.roast.badge" template="PocketCoffee_RoastPage::product/badge.phtml" after="product.info.price">
                <arguments>
                    <argument name="view_model" xsi:type="object">PocketCoffee\RoastPage\ViewModel\RoastList</argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>

The default.xml file adds the footer link on every page. Magento\Framework\View\Element\Html\Link\Current renders a link that is marked current (a <strong>) on its own page, which the test response showed for "Fresh roasts" on /roast/.

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="footer_links">
            <block class="Magento\Framework\View\Element\Html\Link\Current" name="pocketcoffee-roast-link">
                <arguments>
                    <argument name="label" xsi:type="string" translate="true">Fresh roasts</argument>
                    <argument name="path" xsi:type="string">roast</argument>
                </arguments>
            </block>
        </referenceBlock>
    </body>
</page>

Names such as product.info.main, product.info.price, footer_links and page.top are defined by core modules and the Luma theme, and the Luma theme defines some of them (header.links is Luma’s, while top.links belongs to Magento_Theme), so a module that targets a theme-defined name works only with themes that define it. The theme-independent names are those in the Magento_* module layouts.

Caching and layout

All pages are cacheable in the full page cache by default, but only GET and HEAD requests are cacheable. One block with cacheable="false" makes the whole page uncacheable, which is why a per-customer greeting should be a customer-data section rendered by JavaScript, not a non-cacheable block. A block can also cache its own HTML (cache_lifetime and cache_key arguments), which matters when the block runs an expensive query; the test page has no block cache.

Templates

A template is a PHP file with HTML (.phtml) in view/<area>/templates/. A layout reference PocketCoffee_RoastPage::roast/list.phtml means app/code/PocketCoffee/RoastPage/view/frontend/templates/roast/list.phtml. In the file, $block is the block instance, $escaper is the Magento\Framework\Escaper, and $secureRenderer is the SecureHtmlRenderer (the core templates declare both in a docblock at the top for static analysis, as the example does). A template should only print: loops, conditions and escaping are fine; queries, formatting rules and business decisions belong in the view model.

<?php
/**
 * @var \PocketCoffee\RoastPage\Block\RoastSummary $block
 * @var \Magento\Framework\Escaper $escaper
 * @var \Magento\Framework\View\Helper\SecureHtmlRenderer $secureRenderer
 */
/** @var \PocketCoffee\RoastPage\ViewModel\RoastList $viewModel */
$viewModel = $block->getViewModel();
$products = $viewModel->getProducts();
$filter = $viewModel->getOriginFilter();
?>
<div class="pocketcoffee-roast">
    <h2><?= $escaper->escapeHtml($block->getHeading()) ?></h2>
    <?php if ($filter !== ''): ?>
        <p class="pocketcoffee-roast__filter" data-origin="<?= $escaper->escapeHtmlAttr($filter) ?>">
            <?= $escaper->escapeHtml(__('Filter: %1', $filter)) ?>
        </p>
    <?php endif; ?>
    <?php if (!$products): ?>
        <p><?= $escaper->escapeHtml(__('No roasts yet.')) ?></p>
    <?php endif; ?>
    <ul class="pocketcoffee-roast__list">
    <?php foreach ($products as $product): ?>
        <li class="pocketcoffee-roast__item">
            <a href="<?= $escaper->escapeUrl($viewModel->getBatchUrl($product->getSku())) ?>">
                <?= $escaper->escapeHtml($product->getName()) ?>
            </a>
            <span class="pocketcoffee-roast__badge"><?= $escaper->escapeHtml($viewModel->getBadgeText($product)) ?></span>
        </li>
    <?php endforeach; ?>
    </ul>
    <?= $block->getChildHtml('subscribe') ?>
</div>
<?php
$script = "document.querySelector('.pocketcoffee-roast').setAttribute('data-ready', '1');";
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', [], $script, false) ?>
<?php
/**
 * @var \Magento\Framework\View\Element\Template $block
 * @var \Magento\Framework\Escaper $escaper
 */
/** @var \PocketCoffee\RoastPage\ViewModel\RoastList $viewModel */
$viewModel = $block->getViewModel();
?>
<form class="pocketcoffee-roast__subscribe" method="post" action="<?= $escaper->escapeUrl($viewModel->getSubscribeUrl()) ?>">
    <?= $block->getBlockHtml('formkey') ?>
    <label for="pc-roast-email"><?= $escaper->escapeHtml(__('Coffee Club email')) ?></label>
    <input type="email" id="pc-roast-email" name="email" required="required"/>
    <button type="submit" class="action primary"><span><?= $escaper->escapeHtml(__('Join')) ?></span></button>
</form>
<?php
/**
 * @var \Magento\Catalog\Block\Product\View $block
 * @var \Magento\Framework\Escaper $escaper
 */
/** @var \PocketCoffee\RoastPage\ViewModel\RoastList $viewModel */
$viewModel = $block->getViewModel();
$text = $viewModel->getBadgeText($block->getProduct());
?>
<?php if ($text !== ''): ?>
    <div class="pocketcoffee-roast__badge"><?= $escaper->escapeHtml($text) ?></div>
<?php endif; ?>

The rendered markup of GET /roast/ on the test installation (Luma, the sample product PC-ETH-WHL-250) was:

<div class="pocketcoffee-roast">
    <h2>Fresh from the roaster</h2>
            <ul class="pocketcoffee-roast__list">
            <li class="pocketcoffee-roast__item">
            <a href="http://localhost:8082/roast/batch/index/?sku=PC-ETH-WHL-250">
                Single-origin beans, Ethiopia, whole bean, 250 g [hello]            </a>
            <span class="pocketcoffee-roast__badge">Roasted on Oct 20, 2026</span>
        </li>
        </ul>
    <form class="pocketcoffee-roast__subscribe" method="post" action="http://localhost:8082/roast/index/subscribe/">
    <input name="form_key" type="hidden" value="GhCsEThjUGoZjGet" />    <label for="pc-roast-email">Coffee Club email</label>
    ...
</form>
</div>

(The suffix "[hello]" in the product name comes from an unrelated smoke-test plugin that is installed on the shared test store and shows that plugins also run on getName() in templates; the product badge printed "Roasted on Oct 20, 2026" on the product page the same way.)

Escaping

Every dynamic value is escaped for the context in which it is printed, as late as possible. The rules of the Adobe security guide, with the methods of $escaper, are:

Context Method

Text between tags

escapeHtml($value); a second argument lists allowed tags (['a']), never script, iframe, embed, object, video, audio, source or img

An HTML attribute value (class, data-*, title)

escapeHtmlAttr($value)

A URL in href, src, action

escapeUrl($url)

A string inside JavaScript

escapeJs($value); inside an attribute that holds JavaScript, escapeJs first, then escapeHtmlAttr

JSON in a script tag

no escaping, if the JSON was produced by json_encode or the serializer

JSON in an attribute

escapeHtmlAttr

A value that is already HTML (getTitleHtml(), a method named ...Html())

none; never escape twice

An integer, boolean or count()

a type cast is enough

The request of the example with ?origin=<b>x</b> shows the effect on the test installation: the data-origin attribute printed &lt;b&gt;x&lt;&#x2F;b&gt; and the paragraph Filter: &lt;b&gt;x&lt;/b&gt;; the visitor sees the characters, the browser sees no tag. The same input in an unescaped template is a reflected XSS. Escape also translated strings that contain markup: escapeHtml(__('... <a href="%1">...'), ['a']).

Inline scripts and CSP

An inline <script> in a template is a Content Security Policy problem. The Adobe guidance is to print inline scripts through SecureHtmlRenderer, which can register a hash or a nonce for them, and which is also the way to attach event listeners without onclick attributes. The last lines of list.phtml do that:

<?php
$script = "document.querySelector('.pocketcoffee-roast').setAttribute('data-ready', '1');";
?>
<?= /* @noEscape */ $secureRenderer->renderTag('script', [], $script, false) ?>

On the test installation the call produced <script>document.querySelector(...)...</script> and the page contained data-ready. The store ran with the storefront CSP in report-only mode, in which the browser only reports violations; in restrict mode, which is the default on payment pages since 2.4.7, only the whitelisted hashes run. The policy is configured in Security. In the test response no hash for this script was visible in the Content-Security-Policy-Report-Only header, so the registration of hashes was not confirmed here.

Overriding a template

A theme replaces a module template by placing a file of the same name in <theme_dir>/<Vendor_Module>/templates/, for example app/design/frontend/Vendor/theme/Magento_Catalog/templates/product/view/addtocart.phtml. It then no longer receives upstream fixes, so prefer a layout argument, a view model or a plugin on a block method when you only need different data. Template path hints show which file renders a block: in the Admin at Stores > Configuration > Advanced > Developer > Debug (the settings live under dev/debug/*), available in developer mode.

Pitfalls

  • Handle names use the route id. A layout file named after the frontName is silently ignored.

  • A frontName that another module already owns shadows or is shadowed depending on module load order; give each module a distinctive frontName and use before only when you mean to override.

  • Forgetting an HTTP-method interface. A class that implements only ActionInterface accepts every verb, and the CSRF protection of the framework then does not apply to the POST requests it receives.

  • Returning a value from execute() that is not a result object. The framework needs a result (or ResponseInterface); an echo followed by exit breaks the response pipeline, the cache and the tests.

  • Using the ObjectManager or a helper in a template. Both bypass dependency injection and make the template impossible to test; use a view model.

  • A non-cacheable block disables the whole-page cache for every page that includes it; put customer-specific content in a customer-data section instead.

  • Escaping at the wrong time. Escaping in the block or the view model and again in the template shows entities such as &amp;amp;. Return raw values and escape in the template, as the example does.

  • Stale generated code. After changing the constructor of a block, controller or view model, remove generated/code/<Vendor>/<Module> in developer mode; in production mode run setup:di:compile.

  • Static files in production mode. The roast stylesheet is served on demand in developer mode; production mode needs setup:static-content:deploy and cache:flush.

  • ACCS and Hyvä. ACCS cannot run this code at all, and a Hyvä store needs its own template files and Alpine components for the same blocks, so check the compatibility of the theme before relying on the Luma templates.

What was and was not run

On Mage-OS 3.6.0 (Magento 2.4.9 base, PHP 8.5.12, Luma): php -l and phpcs --standard=Magento2 on the module, XML validation of module.xml, routes.xml, di.xml and the three layout files against the framework XSDs, module:enable, setup:upgrade and cache:flush, and HTTP requests for the page (200, title, markup), the friendly URL, the escaped filter, the POST action with and without a valid form key, the method restrictions, the webhook, the JSON action, the product page badge and the footer link. Not run: a browser (the JavaScript that fills the form key and the messages component), production mode with static content deployment, a Hyvä store, a theme override, block caching and the ifconfig and before/after instructions beyond the documented behaviour.