The Storefront
A route, a controller, layout XML, a view model and a template that escapes everything it prints.
<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
<?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:flushWhen 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.
<?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.
<?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.
<?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