The Storefront

A route, a controller, layout XML, a view model and a template that escapes everything it prints.

Syntax<route id="acme_locator" frontName="locator"><module name="Acme_Locator"/></route>

A storefront page in Magento is four small files and no HTML in the PHP.

Route and controller

etc/frontend/routes.xml claims a front name, the first segment of the URL. With frontName="locator", the URL /locator/index/index reaches Controller/Index/Index::execute(): front name, then controller directory, then action class. The controller implements Magento\Framework\App\Action\HttpGetActionInterface and returns a page object. It does not render anything.

Layout and template

Returning a page makes Magento merge the layout files for that request's handles. The handle for this action is acme_locator_index_index, built from route id, controller and action, so view/frontend/layout/acme_locator_index_index.xml is loaded for this URL and no other. That file adds a block with a template and attaches a view model.

View model

A view model is a plain class implementing Magento\Framework\View\Element\Block\ArgumentInterface. It takes its dependencies through the constructor, exposes methods the template calls, and needs no block subclass. Put the repository call here, never in the template.

Escaping

Templates receive $escaper, an instance of Magento\Framework\Escaper. Match the method to the position: escapeHtml between tags, escapeHtmlAttr inside an attribute, escapeUrl in href, escapeJs inside script. Unescaped output in a .phtml is a cross-site scripting hole.

Layout, template and route changes are cached. Run bin/magento cache:flush; in production mode also bin/magento setup:static-content:deploy.

Example

Example · php
<?php
// app/code/Acme/Locator/Controller/Index/Index.php
declare(strict_types=1);

namespace Acme\Locator\Controller\Index;

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

class Index implements HttpGetActionInterface
{
    public function __construct(
        private readonly PageFactory $resultPageFactory
    ) {
    }

    public function execute(): Page
    {
        $resultPage = $this->resultPageFactory->create();
        $resultPage->getConfig()->getTitle()->set(__('Pickup Locations'));

        return $resultPage;
    }
}

// What the reader sees: GET /locator/index/index returns 200 and renders
// the merged layout for the handle acme_locator_index_index inside the
// theme's page template, with "Pickup Locations" as the title tag.
// /locator and /locator/index reach the same action, because Magento
// defaults a missing controller or action segment to "index".
//
// There is no echo and no HTML here. Returning the page object is what
// triggers rendering, and a controller that printed markup instead would
// bypass the layout, the cache and the theme.
//
// Nothing displays until the layout file in the next example exists.
// After adding either file: bin/magento cache:flush

When to use it

  • Add a storefront page listing data from a custom table, such as pickup locations or dealers.
  • Replace a block subclass that exists only to pass data to a template with a view model.
  • Audit a template for unescaped output before a security review.

More examples

routes.xml and the layout file for the handle

Two files, two different effects. routes.xml is what makes the URL exist; the route id acme_locator is also the first part of the layout handle, which is why the handle is acme_locator_index_index rather than locator_index_index. The layout file is loaded only for that handle, so it cannot leak onto other pages. Because the block uses the generic Magento\Framework\View\Element\Template class with a view model argument, this page needs no block subclass at all. The template path Acme_Locator::list.phtml resolves to view/frontend/templates/list.phtml in the module, and a theme can override it without touching the module. Run bin/magento cache:flush after either file.

Example · xml
<?xml version="1.0"?>
<!-- app/code/Acme/Locator/etc/frontend/routes.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="acme_locator" frontName="locator">
            <module name="Acme_Locator"/>
        </route>
    </router>
</config>

<!-- app/code/Acme/Locator/view/frontend/layout/acme_locator_index_index.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="1column"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content">
            <block name="acme.locator.list"
                   class="Magento\Framework\View\Element\Template"
                   template="Acme_Locator::list.phtml">
                <arguments>
                    <argument name="view_model" xsi:type="object">Acme\Locator\ViewModel\LocationList</argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>

The view model

ArgumentInterface is an empty marker interface. Its only job is to let layout XML inject the object as a block argument, and a class without it is rejected when the layout is built. The filter value goes through SearchCriteriaBuilder::addFilter($field, $value, $conditionType = 'eq'), which binds it as a parameter; building the same condition by concatenating a string into addFieldToFilter is how SQL injection gets into a Magento module. Reach for a view model whenever a template needs data: it is unit-testable in isolation, and it keeps you from extending a core block to add one method.

Example · php
<?php
// app/code/Acme/Locator/ViewModel/LocationList.php
declare(strict_types=1);

namespace Acme\Locator\ViewModel;

use Acme\Locator\Api\Data\LocationInterface;
use Acme\Locator\Api\LocationRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\View\Element\Block\ArgumentInterface;

class LocationList implements ArgumentInterface
{
    public function __construct(
        private readonly LocationRepositoryInterface $locationRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder
    ) {
    }

    /**
     * @return LocationInterface[]
     */
    public function getActiveLocations(): array
    {
        $searchCriteria = $this->searchCriteriaBuilder
            ->addFilter(LocationInterface::IS_ACTIVE, 1)
            ->create();

        return $this->locationRepository->getList($searchCriteria)->getItems();
    }
}

The template, with everything escaped

Three shorthands appear here, and each is identical to a longer form you will also meet in Magento templates. `<?= $x ?>` is shorthand for `<?php echo $x; ?>`. The alternative syntax `foreach (...): ... endforeach;` is the same as `foreach (...) { ... }`, and templates prefer it because the closing word says which block ended. `$block->getData('view_model')` is what `$block->getViewModel()` resolves to through AbstractBlock's magic getter, so the two lines do the same thing; the explicit getData call is the one static analysis can follow. Note the escaper method changes with the position: escapeHtmlAttr inside the attribute, escapeUrl in the href, escapeHtml between tags. Using escapeHtml everywhere is not a safe default.

Example · php
<?php
// app/code/Acme/Locator/view/frontend/templates/list.phtml
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */

/** @var \Acme\Locator\ViewModel\LocationList $viewModel */
$viewModel = $block->getData('view_model');
$locations = $viewModel->getActiveLocations();
?>
<?php if (!$locations): ?>
    <p><?= $escaper->escapeHtml(__('No pickup locations are available.')) ?></p>
<?php else: ?>
    <ul class="locator-list">
        <?php foreach ($locations as $location): ?>
            <li data-location-id="<?= $escaper->escapeHtmlAttr((string) $location->getId()) ?>">
                <strong><?= $escaper->escapeHtml($location->getName()) ?></strong>
                <span><?= $escaper->escapeHtml((string) $location->getCity()) ?></span>
                <a href="<?= $escaper->escapeUrl($block->getUrl('locator/view', ['id' => $location->getId()])) ?>">
                    <?= $escaper->escapeHtml(__('Details')) ?>
                </a>
            </li>
        <?php endforeach; ?>
    </ul>
<?php endif; ?>

Discussion

  • Be the first to comment on this lesson.