ViewModel Pattern in Magento 2: Clean phtml Templates Without Bloated Blocks | Mironsoft
AI generated

ViewModel Pattern in Magento 2: Clean phtml Templates Without Bloated Block Classes

· Reading time: approx. 12 minutes · Part of the series: Design Patterns in Magento 2

View
Model
Design Pattern #5 · Structural / Presentation

ViewModel Pattern
in Magento 2

Implement ArgumentInterface, move logic out of Block classes, inject it via Layout XML, unit test it, and use it to full effect in the Hyva Theme, explained in full.

⏱ 12 min. PHP 8.4 Hyva Theme Clean Code

ViewModel Pattern: Why Block Classes Alone Are Not Enough

From the very beginning, Magento 2 has used Block classes as the bridge between PHP logic and phtml templates. In theory: the Block delivers data, the template renders HTML. In practice: Block classes turned into a dumping ground for all sorts of things, database queries, formatting logic, configuration access, HTTP session manipulation. The result was code that was hard to test and hard to maintain.

The ViewModel Pattern is the clean solution. Since Magento 2.2, the ArgumentInterface has been available, a plain marker interface that allows any PHP class to be passed to a Block as an argument. These classes are called ViewModels and encapsulate presentation logic without any dependency on a Magento base class.

1. The Problem With Bloated Block Classes

Let's look at a typical Block class, the kind that unfortunately still shows up all too often in legacy Magento code:


<?php
// PROBLEM: bloated Block class, everything in one place
class ProductInfoBlock extends \Magento\Catalog\Block\Product\View
{
    // Business logic directly in the Block
    public function getDiscountPercent(): int
    {
        $product = $this->getProduct();
        $regular = $product->getPrice();
        $final   = $product->getFinalPrice();
        return $regular > 0 ? (int) round((1 - $final / $regular) * 100) : 0;
    }

    // Formatting logic in the Block
    public function getFormattedPrice(): string
    {
        return '€ ' . number_format($this->getProduct()->getFinalPrice(), 2, ',', '.');
    }

    // Config access in the Block
    public function isBadgeEnabled(): bool
    {
        return (bool) $this->_scopeConfig->getValue('catalog/badge/enabled');
    }

    // Repository call in the Block
    public function getRelatedBlogPosts(): array
    {
        return $this->postRepository->getByProductId($this->getProduct()->getId());
    }
}

// PROBLEMS:
// 1. Extends a complex base class, hard to mock in tests
// 2. Mixed responsibilities (logic + rendering + data access)
// 3. Tightly coupled to the Magento framework
// 4. Barely unit-testable

2. Creating a ViewModel: Implementing ArgumentInterface

A ViewModel is a PHP class that implements only Magento\Framework\View\Element\Block\ArgumentInterface. This interface has no methods, it is a marker interface that tells Magento the class is allowed to be injected as a Block argument.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Pricing\Helper\Data as PricingHelper;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;

/**
 * ViewModel for the product detail page.
 * Provides view-specific data without cluttering the Block class.
 */
class ProductDetailViewModel implements ArgumentInterface
{
    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig,
        private readonly PricingHelper        $pricingHelper,
        private readonly PostRepositoryInterface $postRepository,
        private readonly SearchCriteriaBuilder   $searchCriteriaBuilder
    ) {}

    /**
     * Returns the discount percentage for the given product.
     */
    public function getDiscountPercent(ProductInterface $product): int
    {
        $regularPrice = (float) $product->getPriceInfo()
            ->getPrice('regular_price')->getValue();
        $finalPrice = (float) $product->getPriceInfo()
            ->getPrice('final_price')->getValue();

        if ($regularPrice <= 0 || $finalPrice >= $regularPrice) {
            return 0;
        }

        return (int) round((1 - $finalPrice / $regularPrice) * 100);
    }

    /**
     * Formats a price according to the current store's currency settings.
     */
    public function formatPrice(float $price): string
    {
        return (string) $this->pricingHelper->currency($price, true, false);
    }

    /**
     * Returns whether the sale badge feature is enabled in config.
     */
    public function isSaleBadgeEnabled(): bool
    {
        return (bool) $this->scopeConfig->getValue(
            'catalog/sale_badge/enabled',
            \Magento\Store\Model\ScopeInterface::SCOPE_STORE
        );
    }

    /**
     * Returns related blog posts for the given product.
     */
    public function getRelatedBlogPosts(int $productId): array
    {
        $searchCriteria = $this->searchCriteriaBuilder
            ->addFilter('product_id', $productId)
            ->addFilter('status', 'published')
            ->setPageSize(3)
            ->create();

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

    /**
     * Returns true if the product is on sale (has special price).
     */
    public function isOnSale(ProductInterface $product): bool
    {
        return $this->getDiscountPercent($product) > 0;
    }
}

Advantages over a Block class:

  • No dependency on a Magento base class, easy to mock
  • Clear, single responsibility: presentation logic for the product detail page
  • Constructor property promotion: clean, compact
  • Configurable via DI, swappable via di.xml

3. Injecting a ViewModel via Layout XML

The ViewModel is passed to the Block as an argument, configured in the Layout XML. No PHP code is needed in the Block or the template to instantiate the ViewModel.


<!-- app/code/Mironsoft/Catalog/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>
        <!-- Inject ViewModel into existing product info block -->
        <referenceBlock name="product.info">
            <arguments>
                <!-- The ViewModel is injected as 'view_model' argument -->
                <argument name="view_model" xsi:type="object">
                    Mironsoft\Catalog\ViewModel\ProductDetailViewModel
                </argument>
            </arguments>
        </referenceBlock>

        <!-- Or inject into a new block with its template -->
        <referenceContainer name="product.info.main">
            <block class="Magento\Framework\View\Element\Template"
                   name="mironsoft.product.badge"
                   template="Mironsoft_Catalog::product/badge.phtml"
                   after="product.info.price">
                <arguments>
                    <argument name="view_model" xsi:type="object">
                        Mironsoft\Catalog\ViewModel\ProductDetailViewModel
                    </argument>
                </arguments>
            </block>
        </referenceContainer>
    </body>
</page>

4. Using a ViewModel in the phtml Template

In the template, the ViewModel is retrieved via $block->getData('view_model'). This is the only point where the Block and the ViewModel are connected, with no PHP logic in the template itself.


<?php
/**
 * @var \Magento\Catalog\Block\Product\View $block
 * @var \Mironsoft\Catalog\ViewModel\ProductDetailViewModel $viewModel
 */
$viewModel = $block->getData('view_model');
$product   = $block->getProduct();
$discount  = $viewModel->getDiscountPercent($product);
$isOnSale  = $viewModel->isOnSale($product);
?>

<div class="product-badge-wrapper">

    <?php if ($viewModel->isSaleBadgeEnabled() && $isOnSale): ?>
    <div class="sale-badge" x-data="{ show: true }" x-show="show">
        <span class="bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full">
            -<?= (int) $discount ?>%
        </span>
    </div>
    <?php endif; ?>

    <div class="product-price">
        <span class="final-price text-2xl font-bold text-slate-900">
            <?= $viewModel->formatPrice((float) $product->getFinalPrice()) ?>
        </span>
        <?php if ($isOnSale): ?>
        <span class="regular-price text-slate-400 line-through text-sm ml-2">
            <?= $viewModel->formatPrice((float) $product->getPrice()) ?>
        </span>
        <?php endif; ?>
    </div>

    <!-- Related blog posts from ViewModel -->
    <?php $relatedPosts = $viewModel->getRelatedBlogPosts((int) $product->getId()); ?>
    <?php if (!empty($relatedPosts)): ?>
    <div class="related-posts mt-6">
        <h3 class="text-sm font-semibold text-slate-600 mb-3">Related blog posts</h3>
        <ul class="space-y-2">
            <?php foreach ($relatedPosts as $post): ?>
            <li>
                <a href="/blog/<?= $block->escapeHtmlAttr($post->getUrlKey()) ?>"
                   class="text-blue-600 hover:underline text-sm">
                    <?= $block->escapeHtml($post->getTitle()) ?>
                </a>
            </li>
            <?php endforeach; ?>
        </ul>
    </div>
    <?php endif; ?>

</div>

5. Multiple ViewModels per Block

A Block can have multiple ViewModels, one per responsibility. This keeps ViewModels small and focused (Single Responsibility Principle).


<!-- Multiple ViewModels via Layout XML -->
<block class="Magento\Framework\View\Element\Template"
       name="mironsoft.product.detail"
       template="Mironsoft_Catalog::product/detail.phtml">
    <arguments>
        <!-- ViewModel for pricing/badge logic -->
        <argument name="pricing_view_model" xsi:type="object">
            Mironsoft\Catalog\ViewModel\ProductPricingViewModel
        </argument>
        <!-- ViewModel for social sharing -->
        <argument name="social_view_model" xsi:type="object">
            Mironsoft\SocialShare\ViewModel\SocialShareViewModel
        </argument>
        <!-- ViewModel for reviews -->
        <argument name="reviews_view_model" xsi:type="object">
            Mironsoft\Reviews\ViewModel\ProductReviewsViewModel
        </argument>
    </arguments>
</block>

<?php
// In the template: retrieve each ViewModel separately
/** @var \Mironsoft\Catalog\ViewModel\ProductPricingViewModel $pricingVm */
$pricingVm = $block->getData('pricing_view_model');

/** @var \Mironsoft\SocialShare\ViewModel\SocialShareViewModel $socialVm */
$socialVm = $block->getData('social_view_model');

/** @var \Mironsoft\Reviews\ViewModel\ProductReviewsViewModel $reviewsVm */
$reviewsVm = $block->getData('reviews_view_model');
?>

6. Unit Tests for ViewModels, Simple and Fast

The biggest advantage of the ViewModel Pattern is how easy it is to test. Since ViewModels do not extend any Magento base class, they can be tested with plain PHPUnit, with no Magento bootstrap required.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\ViewModel;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Mironsoft\Catalog\ViewModel\ProductDetailViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Pricing\Helper\Data as PricingHelper;
use Magento\Catalog\Model\Product\Type\Price;

class ProductDetailViewModelTest extends TestCase
{
    private ProductDetailViewModel $viewModel;
    private ScopeConfigInterface&MockObject $scopeConfigMock;
    private PricingHelper&MockObject $pricingHelperMock;

    protected function setUp(): void
    {
        $this->scopeConfigMock   = $this->createMock(ScopeConfigInterface::class);
        $this->pricingHelperMock = $this->createMock(PricingHelper::class);

        $postRepositoryMock       = $this->createMock(\Mironsoft\Blog\Api\PostRepositoryInterface::class);
        $searchCriteriaBuilderMock = $this->createMock(\Magento\Framework\Api\SearchCriteriaBuilder::class);

        // Inject all dependencies via constructor, no Magento bootstrap needed!
        $this->viewModel = new ProductDetailViewModel(
            $this->scopeConfigMock,
            $this->pricingHelperMock,
            $postRepositoryMock,
            $searchCriteriaBuilderMock
        );
    }

    public function testGetDiscountPercentReturnsZeroWhenNoDiscount(): void
    {
        $product = $this->createProductMock(regularPrice: 100.0, finalPrice: 100.0);
        $this->assertSame(0, $this->viewModel->getDiscountPercent($product));
    }

    public function testGetDiscountPercentCalculatesCorrectly(): void
    {
        $product = $this->createProductMock(regularPrice: 100.0, finalPrice: 75.0);
        $this->assertSame(25, $this->viewModel->getDiscountPercent($product));
    }

    public function testIsOnSaleReturnsTrueWhenDiscountExists(): void
    {
        $product = $this->createProductMock(regularPrice: 100.0, finalPrice: 80.0);
        $this->assertTrue($this->viewModel->isOnSale($product));
    }

    public function testIsSaleBadgeEnabledReadsFromConfig(): void
    {
        $this->scopeConfigMock
            ->expects($this->once())
            ->method('getValue')
            ->with('catalog/sale_badge/enabled', $this->anything())
            ->willReturn('1');

        $this->assertTrue($this->viewModel->isSaleBadgeEnabled());
    }

    /** Helper: creates a product mock with specific price info. */
    private function createProductMock(float $regularPrice, float $finalPrice): ProductInterface&MockObject
    {
        $regularPriceMock = $this->createMock(\Magento\Framework\Pricing\Price\PriceInterface::class);
        $regularPriceMock->method('getValue')->willReturn($regularPrice);

        $finalPriceMock = $this->createMock(\Magento\Framework\Pricing\Price\PriceInterface::class);
        $finalPriceMock->method('getValue')->willReturn($finalPrice);

        $priceInfoMock = $this->createMock(\Magento\Framework\Pricing\PriceInfoInterface::class);
        $priceInfoMock->method('getPrice')
            ->willReturnMap([
                ['regular_price', $regularPriceMock],
                ['final_price', $finalPriceMock],
            ]);

        $product = $this->createMock(ProductInterface::class);
        $product->method('getPriceInfo')->willReturn($priceInfoMock);

        return $product;
    }
}

These tests run in milliseconds, need no database connection and no Magento bootstrap. That is the fundamental difference from tests for Block classes, which depend on the full Magento bootstrap.

7. ViewModel in the Hyva Theme

In the Hyva Theme, the ViewModel Pattern matters even more than it does in Luma. Since Hyva consistently favors clean phtml templates paired with Alpine.js, you need a clear separation between PHP data delivery (ViewModel) and JavaScript interactivity (Alpine.js).


<?php
/**
 * Hyva Theme Template: product/badge.phtml
 * ViewModel delivers PHP data, Alpine.js handles the interactivity.
 *
 * @var \Magento\Framework\View\Element\Template $block
 * @var \Mironsoft\Catalog\ViewModel\ProductBadgeViewModel $viewModel
 * @var \Hyva\Theme\Model\ViewModelRegistry $viewModels
 * @var \Hyva\Theme\ViewModel\HyvaCsp $hyvaCsp
 */
$viewModel  = $block->getData('view_model');
$product    = $block->getProduct();
$hyvaCsp    = $viewModels->require(\Hyva\Theme\ViewModel\HyvaCsp::class);

// Prepare PHP data for Alpine.js, always escape it!
$badgeData = [
    'discount'    => $viewModel->getDiscountPercent($product),
    'isNew'       => $viewModel->isNewProduct($product),
    'isBestSeller'=> $viewModel->isBestSeller($product),
    'badgeText'   => $viewModel->getBadgeText($product),
];
?>

<?php if ($viewModel->hasBadge($product)): ?>
<div x-data="productBadge(<?= $block->escapeHtmlAttr(json_encode($badgeData)) ?>)"
     class="absolute top-2 left-2 z-10">

    <template x-if="badge.discount > 0">
        <span class="bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full"
              x-text="'-' + badge.discount + '%'"></span>
    </template>

    <template x-if="badge.isNew && badge.discount === 0">
        <span class="bg-blue-500 text-white text-xs font-bold px-2 py-1 rounded-full">NEW</span>
    </template>

    <template x-if="badge.isBestSeller">
        <span class="bg-amber-500 text-white text-xs font-bold px-2 py-1 rounded-full ml-1">
            Bestseller
        </span>
    </template>

</div>
<?php endif; ?>

<script>
function productBadge(badgeData) {
    return { badge: badgeData };
}
</script>
<?php $hyvaCsp->registerInlineScript() ?>

The pattern: PHP (via the ViewModel) delivers the initial data, Alpine.js renders it reactively. No PHP logic in the template, no Magento classes in Alpine.js.

8. Anti-Patterns With the ViewModel Pattern


<?php
// ANTI-PATTERN 1: rendering logic in the ViewModel
class BadViewModel implements ArgumentInterface
{
    // ViewModels should NOT output HTML
    public function renderBadge(ProductInterface $product): string
    {
        return '<span class="badge">-' . $this->getDiscount($product) . '%</span>';
        // HTML belongs in the template, not in a PHP class
    }
}

// ANTI-PATTERN 2: Block reference inside the ViewModel
class AnotherBadViewModel implements ArgumentInterface
{
    public function __construct(
        private readonly \Magento\Framework\View\Element\Template $block // WRONG!
    ) {}
    // A ViewModel should not know about the Block, that is circular
}

// ANTI-PATTERN 3: session access in the ViewModel without a Proxy
class AlsoBadViewModel implements ArgumentInterface
{
    public function __construct(
        private readonly \Magento\Customer\Model\Session $session // No Proxy!
    ) {}
    // The Session must be injected as a Proxy
}

// CORRECT:
class GoodViewModel implements ArgumentInterface
{
    public function __construct(
        private readonly \Magento\Customer\Model\Session\Proxy $session // With Proxy!
    ) {}
}

Mironsoft

Hyva Theme & Magento Clean Code

Time to refactor and modernize your Magento code?

Refactor Block classes into ViewModels, clean up legacy code, introduce PHPUnit tests, and bring your Magento modules up to a clean code standard.

Block refactoring
We analyze bloated Block classes and cleanly move the logic into focused ViewModels.
Hyva migration
We migrate legacy Luma templates to the Hyva Theme with Alpine.js and a clean ViewModel pattern.
PHPUnit tests
A unit test suite for ViewModels without a Magento bootstrap: fast, isolated, full coverage.

9. Summary

The ViewModel Pattern is the cleanest way to encapsulate presentation logic in Magento 2. Implement ArgumentInterface, inject it via Layout XML, retrieve it in the template. The result: testable, maintainable code with no Magento framework overhead in the logic layer.

ViewModel Pattern in Magento 2, the rules at a glance

Implement ArgumentInterface

No extending Magento classes. Implement only ArgumentInterface. Use constructor property promotion. No HTML inside the ViewModel.

Layout XML injection

xsi:type="object" as the argument. Name it view_model or something descriptive. In the template via $block->getData('view_model').

Unit tests

Standard PHPUnit with no Magento bootstrap. All dependencies as mocks. Fast, isolated, reliable. No ObjectManager needed in tests.

Hyva Theme

PHP data via the ViewModel, Alpine.js for interactivity. Pass data to Alpine as JSON via json_encode() + escapeHtmlAttr(). A clean separation.

10. FAQ: ViewModel Pattern in Magento 2

1 ViewModel vs. Block, what is the difference?
Block: Magento base class, rendering, caching, child Blocks. ViewModel: only ArgumentInterface, no Magento dependency, pure logic. Modern recommendation: Block for template and rendering, ViewModel for all logic and data access.
2 How do I inject a ViewModel into multiple Blocks?
Add it as an argument to each Block in the Layout XML: <argument name="view_model" xsi:type="object">VendorViewModel</argument>. Since the ViewModel is a singleton (shared), the same instance is shared, which is not a performance problem.
3 Can I cache ViewModels?
ViewModels are singletons (shared), instantiated once and shared across all Blocks in the request. Internal results can be stored via a property cache (private ?array $cache = null;) so expensive operations (DB queries) only run once.
4 ViewModel vs. Helper, which one should I use?
Helper (AbstractHelper) is an outdated Magento 1 concept, extends a Magento base class, hard to test. ViewModel: only ArgumentInterface, no base class, easy to test. New modules: always ViewModels. Existing Helpers: refactor into ViewModels over time.
5 Can a ViewModel access the Customer Session?
Yes, but always as a Proxy: inject Magento\Customer\Model\Session\Proxy. Without a Proxy the Session gets initialized too early and can cause page cache problems. This applies to any class that should be lazy-loaded.
6 How do I test a ViewModel with PHPUnit?
Instantiate the ViewModel directly in setUp() with mocked dependencies, no Magento bootstrap needed. Call the methods directly and check the results. Tests run in milliseconds. This is the biggest advantage over Block tests, which need the full Magento bootstrap.
7 How do I use ViewModels in the Hyva Theme without a custom Block class?
Set the Block to Magento\Framework\View\Element\Template (no custom Block needed) and inject the ViewModel via Layout XML. In the template: $viewModel = $block->getData('view_model'). Pass data to Alpine.js as JSON: x-data="fn()".
8 Can I configure a ViewModel with Virtual Types?
Yes, create a Virtual Type in di.xml and use it as the ViewModel class in the Layout XML. Useful when the same ViewModel class needs different configurations for different Blocks. No extra PHP code needed, just di.xml configuration.
9 Is a ViewModel allowed to inject other ViewModels?
Technically possible, but not recommended. It usually signals too broad a responsibility (violates Single Responsibility). Better: inject a shared Service instead. A ViewModel that needs other ViewModels should be split into several focused ViewModels.
10 Which methods belong in a ViewModel and which do not?
IN a ViewModel: data retrieval (config, repository), formatting (price, date), calculations, condition checks. NOT in a ViewModel: outputting HTML, calling Block methods, redirects, database write operations, session manipulation without a clear reason. A ViewModel is a data provider, not a controller.