Hyvä ViewModel Pattern in the Frontend: Keeping ViewModels Cleanly Separated From Blocks
AI generated
Hyvä
phtml
Hyvä · Magento 2 · Tailwind CSS · Alpine.js
Hyvä ViewModel Pattern in the Frontend
Keeping data cleanly separated from blocks

Anyone who writes business logic directly into block classes ends up with Magento templates that can neither be tested in isolation nor reused cleanly. The Hyvä ViewModel pattern strictly separates data preparation from presentation, makes every rule verifiable as a plain PHP class with PHPUnit, and keeps phtml templates limited to pure output.

18 min read ArgumentInterface · di.xml · repository injection · PHPUnit Magento 2.4.8-p4 · PHP 8.4 · Hyvä Themes

1. Why Hyvä pushes ViewModels instead of block logic

In classic Luma templates it was common to write formatting logic, price calculations and visibility rules directly into the block class that extends \Magento\Framework\View\Element\Template. The result was block classes with dozens of methods mixing business logic, data access and presentation details. Hyvä deliberately breaks with this pattern: instead of adding new methods to the block class, Hyvä consistently demands the Hyvä ViewModel pattern, where every business rule lives in a standalone, injectable PHP class.

The reasoning is single responsibility: a block class should connect layout XML, child blocks and the template, not implement business rules. A Hyvä ViewModel takes on exactly that one job, preparing data for a template so that the phtml file only has to output it. Because a ViewModel has no dependency on Context, Registry or the full block constructor graph, it can be instantiated in isolation, tested in isolation, and reused across multiple blocks without side effects.

This separation pays off especially in growing projects. Without Hyvä ViewModel discipline, business logic quietly creeps into block classes, into layout XML arguments, or even directly into the phtml template, where it can neither be reused nor tested in isolation. With the ViewModel pattern, every rule stays in exactly one place, with a clear constructor contract and no hidden coupling to Magento's rendering cycle.

2. The ArgumentInterface: the contract behind every Hyvä ViewModel

Technically, a Hyvä ViewModel is nothing more than a PHP class that implements \Magento\Framework\View\Element\Block\ArgumentInterface. This interface already exists in the Magento core framework, it is a pure marker interface without a single method. Its only purpose: the object manager and the argument resolution in di.xml use it to recognize that a class is a valid block argument of type "ViewModel".

That is exactly why nobody should invent their own ViewModelInterface. A custom interface breaks compatibility with Hyvä core templates, with PHPStorm inspections built for Hyvä projects, and with every convention other modules and extensions expect. The core's ArgumentInterface is deliberately kept empty so it functions as a pure type marker, without forcing a method that would be unsuitable for every conceivable ViewModel use case.


<?php

declare(strict_types=1);

namespace Magento\Framework\View\Element\Block;

/**
 * Marker interface every Hyvä ViewModel must implement.
 * It intentionally declares no methods - it exists solely so that
 * di.xml can resolve the "view_model" argument against a stable, framework-owned contract.
 */
interface ArgumentInterface
{
}

// --------------------------------------------------------------------------

<?php

declare(strict_types=1);

namespace Vendor\Module\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;

/**
 * Minimal Hyvä ViewModel skeleton.
 * No parent class, no coupling to the block or the template - just a plain
 * PHP class that fulfills the framework's ArgumentInterface contract.
 */
class ProductBadge implements ArgumentInterface
{
}

Because ArgumentInterface prescribes no methods, every Hyvä ViewModel defines its own public API freely, according to its business need. That is intentional: a ViewModel for product prices needs different methods than a ViewModel for customer context. The only convention Hyvä projects should follow is a descriptive class name and a namespace that clearly signals responsibility, such as Vendor\Module\ViewModel\ProductBadge instead of a generic Helper or Util.

3. Registering a ViewModel: di.xml and $block->getViewModel()

A Hyvä ViewModel is not hardwired into the block class constructor, it is registered via di.xml as a block argument. The target block type that renders the template is configured with an argument named view_model and xsi:type="object". Magento resolves this argument through the object manager when the block is built and automatically hands over the finished ViewModel instance, complete with all of its own constructor dependencies.


<!-- File: app/code/Vendor/Module/etc/frontend/di.xml -->
<?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\Catalog\Block\Product\View">
        <arguments>
            <!-- Registers the Hyvä ViewModel as a constructor argument named "view_model" -->
            <argument name="view_model" xsi:type="object">Vendor\Module\ViewModel\ProductBadge</argument>
        </arguments>
    </type>
</config>

In the template you access the Hyvä ViewModel through $block->getViewModel(), even though the block class itself defines no such method. The reason lies in \Magento\Framework\DataObject, which every block class ultimately extends: its magic __call() mechanism translates getViewModel() into getData('view_model') automatically, by converting the CamelCase method name to snake_case. This exact mechanism returns the instance injected via di.xml, without any custom getter on the block class.


<?php
/** @var \Magento\Catalog\Block\Product\View $block */
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Vendor\Module\ViewModel\ProductBadge $productBadge */
$productBadge = $block->getViewModel();
$sku = $block->getProduct()->getSku();
?>
<?php if ($productBadge->hasBadge($sku)): ?>
    <span class="inline-flex items-center rounded-full bg-orange-100 px-3 py-1 text-xs font-semibold text-orange-700">
        <?= $escaper->escapeHtml($productBadge->getBadgeLabel($sku)) ?>
    </span>
<?php endif; ?>

4. Practical example: a Hyvä ViewModel with repository injection

The real value of a Hyvä ViewModel only shows once it gets real dependencies injected, such as a repository. The following example loads a product via ProductRepositoryInterface, checks whether it was recently created or has an active special price, and hands the template a fully formatted badge label. With PHP 8.4, constructor property promotion and readonly properties make this particularly compact and readable.


<?php

declare(strict_types=1);

namespace Vendor\Module\ViewModel;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Locale\CurrencyInterface;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\StoreManagerInterface;

/**
 * Prepares product badge data (New / Sale) for the product view template.
 * All aggregation and formatting happens here - the phtml template only renders
 * the value this class returns.
 */
class ProductBadge implements ArgumentInterface
{
    private const NEW_PRODUCT_DAYS = 30;

    /**
     * @param ProductRepositoryInterface $productRepository Loads the product entity by SKU.
     * @param StoreManagerInterface $storeManager Provides the current store context.
     * @param CurrencyInterface $currency Formats prices in the current store currency.
     */
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly StoreManagerInterface $storeManager,
        private readonly CurrencyInterface $currency,
    ) {
    }

    /**
     * Determines whether the given product should show a badge at all.
     *
     * @param string $sku SKU of the product currently rendered by the block.
     * @return bool True if a badge label should be displayed.
     * @throws NoSuchEntityException If no product exists for the given SKU.
     */
    public function hasBadge(string $sku): bool
    {
        $product = $this->productRepository->get($sku);

        return $this->isNew($product) || $this->hasDiscount($product);
    }

    /**
     * Builds the human-readable badge label for the current product.
     *
     * @param string $sku SKU of the product currently rendered by the block.
     * @return string The badge text, e.g. "New" or "-20%".
     * @throws NoSuchEntityException If no product exists for the given SKU.
     */
    public function getBadgeLabel(string $sku): string
    {
        $product = $this->productRepository->get($sku);

        if ($this->hasDiscount($product)) {
            $percent = $this->calculateDiscountPercent($product);
            return sprintf('-%d%%', $percent);
        }

        return __('New')->render();
    }

    /**
     * Checks whether the product was created within the configured "new" window.
     *
     * @param ProductInterface $product The product entity to inspect.
     * @return bool True if the product counts as new.
     */
    private function isNew(ProductInterface $product): bool
    {
        $createdAt = strtotime((string) $product->getCreatedAt());
        $threshold = strtotime(sprintf('-%d days', self::NEW_PRODUCT_DAYS));

        return $createdAt !== false && $createdAt >= $threshold;
    }

    /**
     * Checks whether the product currently has a special price lower than its regular price.
     *
     * @param ProductInterface $product The product entity to inspect.
     * @return bool True if a special price discount is active.
     */
    private function hasDiscount(ProductInterface $product): bool
    {
        $specialPrice = (float) $product->getData('special_price');

        return $specialPrice > 0.0 && $specialPrice < (float) $product->getPrice();
    }

    /**
     * Calculates the discount percentage between regular price and special price.
     *
     * @param ProductInterface $product The product entity to inspect.
     * @return int Rounded discount percentage.
     */
    private function calculateDiscountPercent(ProductInterface $product): int
    {
        $price = (float) $product->getPrice();
        $specialPrice = (float) $product->getData('special_price');

        if ($price <= 0.0) {
            return 0;
        }

        return (int) round((1 - $specialPrice / $price) * 100);
    }
}

Notice that none of the three injected dependencies, ProductRepositoryInterface, StoreManagerInterface and CurrencyInterface, has anything to do with rendering. That is exactly the core idea behind Hyvä ViewModel: the class receives precisely the business building blocks it needs for data preparation, injected via constructor property promotion, without dragging along the full block constructor with Context, Registry and a dozen other legacy dependencies.

5. Presentation logic in the ViewModel, pure output in the template

The central rule of the Hyvä ViewModel pattern is: formatting, aggregation and visibility rules belong in the ViewModel, the phtml template is only allowed to output the result. A typical anti-pattern looks like calculating a discount directly in the template with <?php $percent = round((1 - $special / $price) * 100); ?>. That puts business logic into a file that neither PHPStan can analyze cleanly nor PHPUnit can test in isolation.

With the Hyvä ViewModel approach, the same code section in the template shrinks to a single method call: $productBadge->getBadgeLabel($sku). Every change to the discount logic, such as a new rounding rule or an additional visibility criterion like a store-view restriction, happens exclusively inside the ViewModel class. The template stays unchanged as long as the public method signature does not change, which considerably reduces merge conflicts and frontend regression testing.

This separation also eases teamwork: frontend developers who primarily work with Tailwind classes and Alpine.js interactions do not need to understand the discount calculation to adjust the markup. Backend developers who change the business rule in a Hyvä ViewModel risk no accidental breakage of the template structure, because they work exclusively inside the PHP class.

6. Combining multiple ViewModels in one template

In practice, a single Hyvä ViewModel per template is rarely enough. A product detail page often needs one ViewModel for price data, another for customer context, for example whether the logged-in customer belongs to a special price group, and possibly yet another for stock display. Since the default argument name view_model can only be assigned once per block, every additional ViewModel needs its own, descriptive argument name in di.xml.

Naming conflicts are avoided by giving each additional Hyvä ViewModel its own argument name, such as price_view_model or customer_context_view_model. Thanks to the same CamelCase-to-snake_case mechanism that already maps getViewModel() to view_model, price_view_model becomes reachable in the template via $block->getPriceViewModel(), and customer_context_view_model via $block->getCustomerContextViewModel(). This way, any number of ViewModels can be registered on a single block without their responsibilities overlapping or one instance overwriting another.

A proven convention is to register exactly one Hyvä ViewModel per business concept and to avoid building catch-all ViewModels that bundle several independent responsibilities. A ViewModel that simultaneously manages price formatting, customer context and stock display violates the same single responsibility rule that the pattern was supposed to banish from the block class in the first place, just one level deeper.

7. Testability: a Hyvä ViewModel as a plain PHP class

The biggest practical advantage of a Hyvä ViewModel shows up in testing. Instantiating a block class normally requires a full Context object graph with request, layout, cache state and dozens of other collaborators, which makes genuine unit tests practically impossible and forces most teams into slow integration tests with a full Magento bootstrap. A ViewModel, on the other hand, only needs its own interfaces declared in the constructor, which can be fully replaced with PHPUnit mocks.


<?php

declare(strict_types=1);

namespace Vendor\Module\Test\Unit\ViewModel;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Locale\CurrencyInterface;
use Magento\Store\Model\StoreManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Vendor\Module\ViewModel\ProductBadge;

/**
 * Verifies the badge logic of ProductBadge without booting the Magento framework.
 */
class ProductBadgeTest extends TestCase
{
    private ProductRepositoryInterface&MockObject $productRepository;
    private ProductBadge $viewModel;

    /**
     * Builds the ViewModel with mocked collaborators before each test.
     *
     * @return void
     */
    protected function setUp(): void
    {
        $this->productRepository = $this->createMock(ProductRepositoryInterface::class);
        $storeManager = $this->createMock(StoreManagerInterface::class);
        $currency = $this->createMock(CurrencyInterface::class);

        $this->viewModel = new ProductBadge(
            $this->productRepository,
            $storeManager,
            $currency,
        );
    }

    /**
     * Ensures a product with an active special price is reported as discounted.
     *
     * @return void
     */
    public function testHasBadgeReturnsTrueForDiscountedProduct(): void
    {
        $product = $this->createMock(ProductInterface::class);
        $product->method('getPrice')->willReturn(100.0);
        $product->method('getData')->with('special_price')->willReturn(80.0);
        $product->method('getCreatedAt')->willReturn('2020-01-01 00:00:00');

        $this->productRepository->method('get')->with('TEST-SKU')->willReturn($product);

        self::assertTrue($this->viewModel->hasBadge('TEST-SKU'));
        self::assertSame('-20%', $this->viewModel->getBadgeLabel('TEST-SKU'));
    }
}

This test runs in milliseconds, with no database connection, no Magento bootstrap and no test fixtures. That speed makes the difference in daily development: a Hyvä ViewModel can be verified instantly on every change, while an equivalent test for the same logic inside a block class would almost always have to fall back on expensive integration tests using Magento\TestFramework\TestCase\AbstractController.

8. Caching pitfalls: ViewModels and block HTML cache

A Hyvä ViewModel itself usually holds no state beyond a single request, the real risk lies in the full page cache and the block HTML cache of the block that references the ViewModel. If a block with cacheable="true" in layout XML renders a template whose ViewModel delivers customer-specific data, such as individual discounts or the logged-in customer's name, that personalized HTML ends up in the shared cache and gets served to the next visitor.

The solution does not lie in the ViewModel itself, but in the block's cache configuration. Either the block is excluded from the full page cache with cacheable="false", which costs performance on high-traffic pages, or the dynamic parts are filled in afterwards via typical Hyvä Alpine.js components through a private AJAX call, while the rest of the markup stays cached. A third option is extending getCacheKeyInfo() on the block class so that customer group or another segmentation becomes part of the cache key, which reduces the cache hit rate though.

As a rule of thumb: a Hyvä ViewModel that only delivers product- or catalog-related data is uncritical for the block cache, because the same response applies to every visitor. As soon as a ViewModel processes customer session, cart contents or personalized prices, the caching strategy of the surrounding block must be explicitly reviewed, regardless of how cleanly the ViewModel itself is implemented.

9. Migration: from block class to ViewModel

A typical refactoring starts with an existing block class that directly implements a method like getBadgeLabel(), pulling a repository either from the object manager itself or injecting it via constructor, on top of the full Context object graph. When moving it into a Hyvä ViewModel, exactly this method, along with its business dependencies, moves into the new class, while the block class itself can stay unchanged if it only ever served as a template wrapper anyway.

In the template, only the access path changes: $block->getBadgeLabel($sku) becomes $block->getViewModel()->getBadgeLabel($sku). This minimal change makes the actual migration low-risk in existing projects, because the template and the business logic can be adjusted independently of one another. The following table summarizes the key differences between the old block approach and the new Hyvä ViewModel approach.

Aspect Before: block class After: Hyvä ViewModel Benefit
Data preparation Directly in Block::getBadgeLabel() Extracted into ProductBadge::getBadgeLabel() Single responsibility per class
Testability Full Context, Registry, bootstrap required Plain PHPUnit class with mocks Fast unit tests without Magento
Reuse Bound to a single block class Registrable on any block via di.xml No code duplication
Cache control Logic invisible inside the block HTML cache Explicitly controllable via block cache configuration No silent customer-specific caching
Coupling Inherits the full block constructor graph Implements only ArgumentInterface Minimal, explicit dependency graph

After the migration, the block class remains almost empty in many cases, it only supplies the product context to the template, while every formatting and visibility rule lives inside the Hyvä ViewModel. This step-by-step refactoring can be done method by method, without having to rewrite an entire module at once.

10. Summary

The Hyvä ViewModel pattern solves a structural problem that Luma templates carried around for years: business logic inside block classes that was neither testable in isolation nor clearly scoped. With ArgumentInterface as an empty, framework-owned contract, registration via di.xml as a block argument, and access through $block->getViewModel(), a clear, repeatable structure emerges for every new business rule in the frontend.

The nine aspects covered, from the underlying idea through registration, repository injection, the separation of presentation logic and output, multiple ViewModels per template, testability with PHPUnit, caching pitfalls, all the way to concrete migration, together form a complete picture of how a Hyvä ViewModel should be used in everyday project work. Anyone who applies this structure consistently reduces block classes to their actual job and gains a testable, reusable business logic layer.

Hyvä ViewModel Pattern in the Frontend: The Essentials at a Glance

ArgumentInterface

Empty marker interface from the core. Never invent your own interface, it breaks compatibility with Hyvä conventions.

di.xml registration

Register the view_model argument of type object on the block, access it via $block->getViewModel().

Testability

Plain PHP class with injected interfaces, testable with PHPUnit in milliseconds, without a Magento bootstrap.

Watch caching

Personalized ViewModel data inside cacheable blocks carries the risk of stale, customer-specific HTML.

11. FAQ: Hyvä ViewModel

1What exactly is a Hyvä ViewModel?
A PHP class that implements ArgumentInterface and is registered via di.xml as a block argument. It prepares template data without containing rendering logic.
2Why no custom ViewModelInterface?
ArgumentInterface already exists in the framework and is empty. A custom interface breaks compatibility with Hyvä core templates and conventions.
3How do I register a ViewModel for a block?
Via di.xml with an argument view_model of xsi:type object on the target block type, whose value is the full ViewModel class name.
4Why does getViewModel() work without a custom method?
DataObject::__call() converts getViewModel() automatically into getData('view_model') and returns the instance registered via di.xml.
5Multiple ViewModels on one block?
Yes, with distinct argument names like price_view_model, reachable via getPriceViewModel() thanks to the same name-resolution mechanism.
6How do I test a ViewModel with PHPUnit?
Replace constructor dependencies with createMock() and instantiate the class directly, with no Magento bootstrap or database needed.
7Does a ViewModel fully replace the block class?
Mostly yes for business logic. The block class remains a thin wrapper for layout, child blocks and template connection.
8Caching with customer-specific ViewModel data?
Set the block to cacheable=false, load dynamic parts afterwards via Alpine.js, or extend the cache key via getCacheKeyInfo().
9Does a ViewModel always need a repository?
No. Pure formatting logic without its own data access is also a valid ViewModel. Repository injection is typical, not mandatory.
10How do I migrate block logic step by step?
Move it method by method into the new ViewModel class, switching the template from $block->method() to $block->getViewModel()->method().

Mironsoft

Hyvä frontend architecture and Magento 2 development

Want a Hyvä frontend that is cleanly architected?

We analyze existing block classes, consistently pull business logic into clean ViewModels, and build a PHPUnit test suite that permanently safeguards your Hyvä ViewModel layer.

Code review

Audit of existing block and ViewModel architecture against Hyvä conventions

Refactoring

Extracting business logic from block classes into testable ViewModels

Test coverage

PHPUnit test suites for ViewModels without a full Magento bootstrap