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
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.
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.
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?
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?
<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?
private ?array $cache = null;) so expensive operations (DB queries) only run once.4 ViewModel vs. Helper, which one should I use?
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?
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?
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?
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(= escapeHtmlAttr(json_encode($data)) ?>)".