Templates are where most Magento security bugs and maintenance problems start: business logic mixed with HTML, unescaped output and custom block classes for every small feature. Two habits fix most of this — ViewModels for logic and $escaper for output.

Why ViewModels Instead of Block Classes

  • They use plain constructor injection and do not inherit the large AbstractBlock API.
  • They can be shared by several templates and are easy to unit-test.
  • They work identically in Luma and Hyvä — Hyvä uses them for almost everything.
  • Changing a block's class in layout affects other modules; adding a ViewModel argument does not.

A ViewModel Example

ViewModel/DeliveryEstimate.php
<?php
declare(strict_types=1);

namespace MageServices\Catalog\ViewModel;

use Magento\Catalog\Model\Product;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Framework\View\Element\Block\ArgumentInterface;

class DeliveryEstimate implements ArgumentInterface
{
    private const FREE_DELIVERY_THRESHOLD = 50.0;

    public function __construct(
        private readonly PriceCurrencyInterface $priceCurrency
    ) {
    }

    public function qualifiesForFreeDelivery(Product $product): bool
    {
        return (float) $product->getFinalPrice() >= self::FREE_DELIVERY_THRESHOLD;
    }

    public function getFreeDeliveryThreshold(): string
    {
        return $this->priceCurrency->convertAndFormat(self::FREE_DELIVERY_THRESHOLD, false);
    }
}
view/frontend/layout/catalog_product_view.xml
<?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 name="mageservices.delivery.estimate"
                   template="MageServices_Catalog::product/delivery-estimate.phtml"
                   after="product.info.price">
                <arguments>
                    <argument name="delivery_view_model" xsi:type="object">MageServices\Catalog\ViewModel\DeliveryEstimate</argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>

The Template: Escape Everything

view/frontend/templates/product/delivery-estimate.phtml
<?php
declare(strict_types=1);

use Magento\Catalog\Block\Product\View;
use Magento\Framework\Escaper;
use MageServices\Catalog\ViewModel\DeliveryEstimate;

/** @var View $block */
/** @var Escaper $escaper */
/** @var DeliveryEstimate $delivery */
$delivery = $block->getData('delivery_view_model');
$product = $block->getProduct();
?>
<div class="delivery-estimate"
     data-product-sku="<?= $escaper->escapeHtmlAttr($product->getSku()) ?>">
    <?php if ($delivery->qualifiesForFreeDelivery($product)): ?>
        <p><?= $escaper->escapeHtml(__('This item qualifies for free delivery.')) ?></p>
    <?php else: ?>
        <p><?= $escaper->escapeHtml(__('Free delivery on orders over %1.', $delivery->getFreeDeliveryThreshold())) ?></p>
    <?php endif; ?>
    <a href="<?= $escaper->escapeUrl($block->getUrl('delivery-information')) ?>">
        <?= $escaper->escapeHtml(__('Delivery information')) ?>
    </a>
</div>

Which Escaper Method to Use

ContextMethodExample
Text between tagsescapeHtml()Product names, labels, messages
HTML with allowed tagsescapeHtml($value, ['b', 'a'])Short CMS-like snippets with a strict allow-list
HTML attribute valuesescapeHtmlAttr()title, alt, data-*, value
URLs in href or srcescapeUrl()Links and image URLs
Values inside JavaScript stringsescapeJs()Strings in inline JS — prefer data attributes
CSS valuesescapeCss()Dynamic style values
Already-safe HTML/* @noEscape */Output of child blocks or renderers you control
The old escape helpers on blocks — $block->escapeHtml() and friends — are deprecated, and Magento 2.4.9 removed the deprecated getEscaper() method from core classes. Use the $escaper variable that every template receives.

Common Mistakes

  • Escaping too early. Escape at output, in the template — not in the ViewModel. Data should stay raw until it is rendered so it can be escaped for the right context.
  • Using escapeHtml inside attributes. It does not escape quotes in the same way; use escapeHtmlAttr().
  • Building JSON by hand. Use json_encode() with JSON_HEX_TAG and related flags, or Magento's JSON serializer, then escape for the context.
  • Heavy logic in templates. Loops over collections with database queries belong in a ViewModel or service.
  • Loading data per product in listings. A ViewModel method called for every product on a category page must be cheap or batched.

ViewModels in Hyvä

Hyvä provides a view model registry in every template as $viewModels, so you can use a ViewModel without adding a layout argument:

<?php
declare(strict_types=1);

use Hyva\Theme\Model\ViewModelRegistry;
use Hyva\Theme\ViewModel\CurrentProduct;
use Magento\Framework\Escaper;
use MageServices\Catalog\ViewModel\DeliveryEstimate;

/** @var Escaper $escaper */
/** @var ViewModelRegistry $viewModels */
$product = $viewModels->require(CurrentProduct::class)->get();
$delivery = $viewModels->require(DeliveryEstimate::class);
?>
<div x-data="{ open: false }" class="mt-4">
    <button type="button" class="btn btn-secondary" @click="open = !open" :aria-expanded="open">
        <?= $escaper->escapeHtml(__('Delivery options')) ?>
    </button>
    <p x-show="open" x-cloak class="mt-2 text-sm text-gray-700">
        <?= $escaper->escapeHtml(__('Free delivery on orders over %1.', $delivery->getFreeDeliveryThreshold())) ?>
    </p>
</div>

The same ViewModel class serves both themes. Only the template differs: Luma markup with LESS classes, or Hyvä markup with Tailwind classes and Alpine.js.

Frequently Asked Questions

Is $block->escapeHtml() still allowed?

It is deprecated. Use the $escaper variable available in every template.

Do I need to escape translated strings?

Yes. Translations can contain characters that need escaping and placeholders can contain user data. Wrap __() calls in escapeHtml().

Can ViewModels have state?

Keep them stateless where possible. They are shared instances by default, so per-product state can leak between calls.

🚀

Need help with this on your store?

Our Magento engineers can implement it for you, review your code or take on the whole project.

Explore Magento Extension Development
MS
About the author

Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.