and why ViewModels are now the better third option
In most Magento 2 projects the Block vs. Template decision is made intuitively, rarely with a clear rationale. The result is fat Block classes full of business logic, templates with direct model calls, and code that cannot be tested in isolation. This article shows what actually belongs in a Block class, what belongs in the phtml template, and how ViewModels resolve the separation cleanly in modern Magento 2.4.8 and Hyva projects.
Table of Contents
- 1. Block vs. Template: the actual decision
- 2. What belongs in a Block class
- 3. What belongs in the template, and what does not
- 4. ViewModels: the third option beyond Block and Template
- 5. Anti-patterns from real Magento projects
- 6. Block caching: getCacheKeyInfo, _toHtml and cacheable=false
- 7. getChildBlock and getChildHtml: when block composition is still needed
- 8. Practical example: from a fat Block to a ViewModel
- 9. Block vs. Template vs. ViewModel compared
- 10. Summary
- 11. FAQ
1. Block vs. Template: the actual decision
Every Magento 2 page is produced by the interplay of two building blocks: a Block class that extends \Magento\Framework\View\Element\Template, and a template, a phtml file rendered by exactly that block. The Block vs. Template question sounds trivial, but in practice it is the root of many architecture problems. The common rule of thumb, "Block holds logic, template only displays," is correct at its core, but leaves open which kind of logic belongs in the Block class and which should instead be moved into a service contract or a ViewModel.
In many grown Magento projects this decision is never made consciously. Developers copy an existing Block and template pair, add a method wherever there happens to be space, and business logic ends up somewhere it architecturally does not belong. After a few years, Block classes with twenty methods, direct ResourceModel calls and price calculations sit next to templates that fire their own database queries in parallel. Block vs. Template is therefore not an academic question, it directly decides testability, reusability and maintenance cost of a module.
This article maps out the three relevant building blocks, Block class, template and ViewModel, according to their actual responsibility. It shows concrete anti-patterns from real Magento projects, explains the role of block caching and child block composition, and ends with a full refactoring example that turns a fat Block class into a testable ViewModel with a service contract dependency.
2. What belongs in a Block class
A Block class is essentially an adapter between the layout system and the template. Its legitimate responsibility covers three things: building the child block structure in _prepareLayout(), controlling caching behavior via getCacheKeyInfo() and optionally _toHtml(), and providing already prepared data to the template through public getter methods. Important here: "providing" does not mean "computing." A Block class is allowed to call a service contract or a repository to fetch data, but it should not implement the actual business logic itself.
A clean example: a block that displays recently viewed products may inject a ProductRepositoryInterface instance in the constructor and encapsulate the repository calls in a method getRecentProducts(): array. What does not belong in the Block class is calculating discount tiers, formatting complex pricing logic, or merging multiple data sources with their own conditions. This is exactly the point where a Block class tips over from "view adapter" to "hidden service," and that is exactly what makes unit tests hard, because \Magento\Framework\View\Element\Template is deeply coupled to the layout system, the request object and the rendering context.
For Block vs. Template the first hard rule therefore applies: a Block class prepares data and passes it through, it does not invent new business logic. As soon as a method in the Block class does more than pure delegation to a service contract, for example loops with conditions, aggregations or formatting rules, that is a signal to move this logic into a ViewModel or directly into a service contract.
3. What belongs in the template, and what does not
A template in Magento 2 is a phtml file responsible exclusively for presentation. Allowed are: iterating over already prepared data, conditionally rendering markup based on simple flags, setting Alpine.js data attributes for Hyva components, and escaping every output via $block->escapeHtml(), escapeHtmlAttr() or escapeUrl(). Not allowed are calls to repositories, ResourceModels or the ObjectManager, custom price calculations, date formatting with complex logic, or conditions that actually encode business rules.
In practice the line between Template vs. Block logic is often blurry, because phtml files can technically execute any PHP code. That is exactly the problem: a template that calls ObjectManager::getInstance()->get(SomeModel::class) completely bypasses the dependency injection architecture, is untestable, and silently breaks with any refactoring of the underlying class. A template should never need to know more than what the associated Block or ViewModel exposes through its public interface.
Hyva themes add another dimension: Alpine.js components bind via x-data to values that are serialized from PHP to JSON. This serialization belongs in the template, but preparing the underlying data belongs in the ViewModel or the Block class. A template that applies json_encode() to a raw collection instead of an already normalized array couples the presentation layer to internal data structures that can change at any time.
4. ViewModels: the third option beyond Block and Template
Since Magento 2.2 there is an empty marker interface, \Magento\Framework\View\Element\Block\ArgumentInterface, that forms the basis for ViewModels. A ViewModel is a plain PHP class with no coupling to the view layer at all, no Block, no Request, no Layout. It is bound to any block as an argument via layout XML, and is therefore reusable across multiple blocks and templates without those blocks needing to share a common base class. This resolves the structural problem of Block vs. Template at a new level: business logic moves neither into the Block class nor into the template, but into a self-contained, unit-testable class.
The advantages over a fat Block class are concrete. First, testability: a ViewModel can be instantiated without a Magento bootstrap and tested with mocked service contracts, whereas testing a block usually requires the full layout context. Second, decoupling: a ViewModel knows neither request, response nor layout, it only knows the service contracts it receives in its constructor. Third, reusability: the same ViewModel can be bound to multiple blocks across different layout handles, without building inheritance hierarchies that have historically led to deeply nested Block classes in Magento.
In layout XML a ViewModel is bound to a block via <argument name="view_model" xsi:type="object">. In the template it is accessed via $block->getViewModel(), provided the Block class exposes a typed getter method for it. This small convention matters: instead of using $block->getData('view_model') directly in the template, which offers no type safety at all, the Block class should provide an explicit method getViewModel(): ProductBadgeViewModel. This keeps Block vs. Template vs. ViewModel a clean, type-safe chain instead of implicit data passing through generic arrays.
<!-- File: view/frontend/layout/catalog_product_view.xml -->
<!-- Wiring a ViewModel as an argument to a block, no coupling between blocks required -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block class="Magento\Framework\View\Element\Template"
name="mironsoft.product.badge"
template="Mironsoft_Catalog::product/badge.phtml">
<arguments>
<argument name="view_model" xsi:type="object">
Mironsoft\Catalog\ViewModel\ProductBadge
</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\ViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\View\Element\Block\ArgumentInterface;
/**
* View model providing prepared product badge data for the storefront.
* No View-layer coupling, fully unit testable with a mocked repository.
*/
final class ProductBadge implements ArgumentInterface
{
/**
* @param ProductRepositoryInterface $productRepository Service contract for product access
*/
public function __construct(
private readonly ProductRepositoryInterface $productRepository
) {
}
/**
* Returns the badge label for a given product SKU, or null if none applies.
*
* @param string $sku Product SKU to check
* @return string|null Prepared, already translated badge label
*/
public function getBadgeLabel(string $sku): ?string
{
try {
$product = $this->productRepository->get($sku);
} catch (NoSuchEntityException) {
return null;
}
return $this->resolveBadge($product);
}
/**
* Resolves the badge text based on stock status and special price.
*
* @param ProductInterface $product Loaded product entity
* @return string|null Badge text or null if no badge applies
*/
private function resolveBadge(ProductInterface $product): ?string
{
// @phpstan-ignore-next-line getSpecialPrice not in ProductInterface but present on the model
if ($product->getSpecialPrice()) {
return 'Sale';
}
return null;
}
}
5. Anti-patterns from real Magento projects
The most common Block vs. Template anti-pattern is business logic in the phtml template. A typical example: a template calculates the effective price including tax and discount inside a loop, instead of getting that calculation from a service contract or a ViewModel method. As soon as the tax logic changes, the developer has to search every template that duplicated the calculation, instead of adjusting a single class.
A second, equally common anti-pattern is direct model or ResourceModel calls straight from the template, often via the ObjectManager. This completely bypasses dependency injection, prevents PHPStan or static analysis from recognizing the dependency, and makes the template depend on internal implementation details that can change with any Magento upgrade. This exact pattern was widespread in Luma themes and often reappears unchanged in migrated legacy code inside Hyva templates.
The third anti-pattern concerns missing escaping: echo $someVariable instead of echo $block->escapeHtml($someVariable). As soon as the value comes from customer input, a CMS block or an attribute value, unprotected HTML echo opens a potential cross-site scripting hole. Magento does not enforce escaping automatically in phtml files, which is why every output that is not provably from a static, trusted source must go through escapeHtml(), escapeHtmlAttr() or escapeJs(). In Hyva projects, escapeUrl() for links and Alpine.js data serialization deserve particular attention, because JSON is embedded directly into HTML attributes there.
6. Block caching: getCacheKeyInfo, _toHtml and cacheable=false
Block classes in Magento 2 can cache their rendered output, controlled through the method getCacheKeyInfo(), which returns an array of key segments, typically block class, template, store ID and customer group. Magento combines these segments into a unique cache key and stores the rendered HTML in the configured cache backend. If _toHtml() is overridden, the parent implementation should still be called, unless there is an explicit reason to bypass default caching, for example for blocks whose content changes on every request.
In Hyva projects the caching question looks different than in Luma. Because Hyva deliberately avoids server-side block HTML caching for personalized areas and instead relies on full page caches with client-side lazy loading, cacheable="false" is frequently set in layout XML for dynamic, customer-specific blocks. This typically applies to the mini cart, greeting text with the customer name, or the wishlist counter. These blocks are either completely excluded from full page cache storage, or their dynamic parts are lazy-loaded via Alpine.js from a separate, uncached endpoint, while the static rest of the page stays normally cached.
An important CSP aspect: every inline <script> block in a Hyva template must be registered via $hyvaCsp->registerInlineScript(), otherwise the content security policy blocks execution. This must live in the template, not in the Block class, because the registration is tied to the actual rendering moment. A Block that tries to anticipate CSP registration in the constructor will not work reliably, because the CSP nonce is only finally known at render time.
7. getChildBlock and getChildHtml: when block composition is still needed
Not every structure can be resolved into a single ViewModel. When a page consists of multiple independent, layout-XML-configurable areas, for example the product detail page with reviews, related products and a tab system, the classic block composition via $block->getChildBlock('name') and $block->getChildHtml('name') remains the right tool. Each child block brings its own template, can be independently cacheable, and can be extended or replaced by third-party modules through layout XML without touching the parent block.
The difference to ViewModels lies in purpose: getChildHtml() solves a structural composition problem, assembling multiple independently renderable areas into one page, while a ViewModel solves a data preparation problem within a single template. Both mechanisms do not exclude each other, they complement each other: a parent block can compose several child blocks, while each individual child block uses its own ViewModel for data preparation. Anyone who tries to replace block composition with ViewModels loses the extensibility through layout XML that is indispensable precisely in third-party integrations.
A common mistake in this context: a parent block reaches directly into internal getter methods of the child block via getChildBlock('name') to further process its data. This couples both blocks so tightly that neither can be changed independently anymore. When data needs to be shared between a parent block and a child block, a shared ViewModel bound to both as an argument is almost always the cleaner solution than a direct object reference between two blocks.
8. Practical example: from a fat Block to a ViewModel
The following example shows a typical fat Block class as commonly found in grown Magento projects. The class loads a product collection directly via the ResourceModel, calculates prices inline, and the template output skips escaping in one place. This pattern is hard to test, because a unit test would need to mock the entire object graph of collection, ResourceModel and price helper, and it violates the rule that a Block class should pass data through rather than implement business logic.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\Block;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Framework\View\Element\Template;
use Magento\Framework\View\Element\Template\Context;
/**
* BEFORE: fat block class mixing data access, business logic and rendering concerns.
* Anti-pattern: direct ResourceModel access, price math inline, hard to unit test.
*/
class RecentProducts extends Template
{
public function __construct(
Context $context,
private readonly CollectionFactory $collectionFactory,
array $data = []
) {
parent::__construct($context, $data);
}
/**
* Loads recent products and computes discounted prices inline.
* Anti-pattern: business logic embedded directly in the block class.
*
* @return array
*/
public function getRecentProducts(): array
{
$collection = $this->collectionFactory->create();
$collection->addAttributeToSelect(['name', 'price', 'special_price']);
$collection->setPageSize(5)->setCurPage(1);
$result = [];
foreach ($collection as $product) {
// Anti-pattern: tax and discount math should live in a price service, not here
$price = (float) $product->getPrice();
$special = (float) $product->getSpecialPrice();
$finalPrice = $special > 0 && $special < $price ? $special : $price;
$result[] = [
'name' => $product->getName(),
'price' => $finalPrice,
'url' => $product->getProductUrl(),
];
}
return $result;
}
}
The refactored version cleanly separates three responsibilities: price calculation moves into a PricingHelper-style service contract construct, data preparation moves into a ViewModel, and the Block class is reduced to a thin delegation layer that only passes the ViewModel through in a type-safe way. The template itself accesses already prepared data exclusively via $block->getViewModel() and consistently escapes every output.
<?php
declare(strict_types=1);
namespace Mironsoft\Catalog\ViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory;
use Magento\Framework\Pricing\Helper\Data as PricingHelper;
use Magento\Framework\View\Element\Block\ArgumentInterface;
/**
* AFTER: thin view model, no View-layer coupling, fully unit testable.
* Business logic (price resolution) is delegated to Magento's own pricing helper.
*/
final class RecentProducts implements ArgumentInterface
{
/**
* @param CollectionFactory $collectionFactory Product collection factory
* @param PricingHelper $pricingHelper Service contract for price formatting
*/
public function __construct(
private readonly CollectionFactory $collectionFactory,
private readonly PricingHelper $pricingHelper
) {
}
/**
* Returns the five most recently added products as a plain, template-ready array.
*
* @return array<int, array{name: string, price: string, url: string}>
*/
public function getRecentProducts(): array
{
$collection = $this->collectionFactory->create();
$collection->addAttributeToSelect(['name', 'price', 'special_price']);
$collection->setPageSize(5)->setCurPage(1);
$result = [];
foreach ($collection as $product) {
/** @var ProductInterface $product */
$result[] = [
'name' => $product->getName(),
'price' => $this->pricingHelper->currency($product->getFinalPrice(), true, false),
'url' => $product->getProductUrl(),
];
}
return $result;
}
}
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
/** @var \Mironsoft\Catalog\ViewModel\RecentProducts $viewModel */
$viewModel = $block->getViewModel();
?>
<div class="recent-products" x-data="{ open: false }">
<button type="button" @click="open = !open" class="text-sm font-semibold">
<?= $escaper->escapeHtml(__('Recently Viewed')) ?>
</button>
<ul x-show="open" class="mt-2 space-y-1">
<?php foreach ($viewModel->getRecentProducts() as $product): ?>
<li>
<a href="<?= $escaper->escapeUrl($product['url']) ?>" class="text-sm">
<?= $escaper->escapeHtml($product['name']) ?>
<span class="text-gray-500"><?= $escaper->escapeHtml($product['price']) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
</div>
9. Block vs. Template vs. ViewModel compared
The following table summarizes which responsibility belongs where. It is meant as a quick decision aid for whenever the Block vs. Template vs. ViewModel question comes up again while writing a new module.
| Responsibility | Belongs in Block class | Belongs in template | Belongs in ViewModel |
|---|---|---|---|
| Child block structure | _prepareLayout(), getChildHtml() | No, no structure management | Not responsible |
| Business logic / pricing rules | No, only delegate | No, classic anti-pattern | Yes, with service contract dependency |
| Caching strategy | getCacheKeyInfo(), _toHtml() | No, no cache control | Not responsible |
| Iteration & markup | No, that is presentation | Yes, over already prepared data | Not responsible |
| Escaping output | Provide escapeHtml() | Yes, mandatory for every output | Not responsible |
| Reuse across blocks | No, coupled to view layer | No, bound to one template | Yes, bindable multiple times via layout XML |
| Unit testability without bootstrap | Difficult, needs layout context | Practically not testable | Yes, plain PHP with no view coupling |
The table makes clear that Block vs. Template is not a binary either-or, but becomes a three-way split through the ViewModel. The Block class remains responsible for structural and caching aspects, the template for pure presentation, and the ViewModel takes over data preparation with business logic. Anyone who consistently follows this three-way split reduces both the size of individual classes and the coupling between them.
10. Summary
Block vs. Template ultimately is not a matter of taste, but of responsibility boundaries. A Block class controls child block structure and caching and provides already prepared data, a template renders this data without its own business logic and consistently escapes every output. As soon as data preparation involves real business logic, it belongs in a ViewModel, declared via ArgumentInterface and bound through layout XML, with no coupling to the view layer at all.
The practical benefit shows up mainly in testability and maintainability: a ViewModel can be unit tested without a Magento bootstrap, reused across multiple blocks, and evolved independently of layout details. Anyone who briefly asks the question Block, template or ViewModel for every new requirement prevents business logic from silently accumulating in phtml files or bloated Block classes from the start.
Block vs. Template: the key takeaways
Block class
Controls child block structure, caching via getCacheKeyInfo(), and passes already prepared data through, but does not implement business logic itself.
Template
Pure presentation: iteration, conditional markup, Alpine.js data attributes, consistent escapeHtml() on every output.
ViewModel
Implements ArgumentInterface, encapsulates service contract calls and business logic, unit testable with no view layer coupling.
Avoiding anti-patterns
No ObjectManager in templates, no pricing logic in phtml, no echo without escapeHtml(), no direct model coupling between blocks.
11. FAQ: Block vs. Template vs. ViewModel
1What is the basic difference in Block vs. Template?
2When to use a ViewModel instead of a Block class?
3Is a template allowed to make a repository call?
4How is a ViewModel bound to a block?
5Why is ArgumentInterface an empty interface?
6When do I still need getChildBlock and getChildHtml?
7How does block caching work with getCacheKeyInfo?
8Why cacheable=false in Hyva themes?
9Most common security problem with templates?
10Worth refactoring Block to ViewModel?
Mironsoft
Magento 2 architecture, Hyva themes and ViewModel refactoring
Ready for clean Magento 2 architecture?
From an architecture review to a full ViewModel refactoring: we bring Block, template and ViewModel back into the right order in your project.
Code audit
Systematic review of all Block classes and templates for anti-patterns
Refactoring
Migration to ViewModels with service contracts and full test coverage
Team training
Establishing Block vs. Template vs. ViewModel best practices in your own dev team