You've got some PHP logic that needs to end up in a Magento template. Maybe it's pulling data from config, maybe it's formatting a value for display.
Simple enough -- but where does the code actually go?
If you Google around or ask an AI, you'll probably get pointed toward creating a custom block class. That's how we did it in Magento 1, and honestly, a lot of the answers floating around online still assume that's the way.
But that advice is outdated. Block classes are pretty much legacy at this point -- view models are the modern approach, and they're faster, cleaner, and way easier to test.
So when do you actually use each? Let's dig in.
Why block classes became the default (and why that's a problem)
Block classes have been around since Magento 1. They can do a lot -- interact with the layout, access parent and child blocks, hook into rendering, pass data to templates. The works.
That power is exactly the issue. When something can do everything, developers use it for everything. Even when they just need to pass a string to a template.
Magento 2.2 introduced view models specifically to fix this. A view model does one thing: pass data to templates. No layout access, no rendering hooks, no block traversal.
By stripping out the extras, view models run faster and force you to keep your code focused. They're also dead simple to unit test since they don't drag in layout dependencies.
Building a ViewModel
Let's say we want to show the store name and phone number on the homepage. Classic view model territory -- we just need some data in a template.
We'll set this up in a module called Acme_Store. Here's the registration boilerplate:
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Acme_Store',
__DIR__
);<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Acme_Store"/>
</config>Now the view model itself:
<?php
declare(strict_types=1);
namespace Acme\Store\ViewModel;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Store\Model\ScopeInterface;
use Magento\Store\Model\StoreManagerInterface;
readonly class StoreInfo implements ArgumentInterface
{
public function __construct(
private StoreManagerInterface $storeManager,
private ScopeConfigInterface $scopeConfig,
) {
}
public function getStoreName(): string
{
try {
return $this->storeManager->getStore()->getName();
} catch (NoSuchEntityException) {
return '';
}
}
public function getStorePhone(): string
{
return $this->scopeConfig->getValue(
'general/store_information/phone',
ScopeInterface::SCOPE_STORE
) ?? '';
}
}Take a look at ArgumentInterface -- this trips up a lot of developers.
It's just a marker interface with zero methods. The only thing it does is tell Magento "hey, this class can be injected as a block argument". That's the whole contract.
The rest is straightforward. Inject what you need, write methods that return data. We've got separate methods for name and phone since each should do one thing.
Wiring it up
Now we connect the view model to a template through layout 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="content">
<block name="acme.store.store-info"
template="Acme_Store::store-info.phtml">
<arguments>
<argument name="store_info_view_model"
xsi:type="object">Acme\Store\ViewModel\StoreInfo</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>The xsi:type="object" tells Magento to actually instantiate this class rather than treating it as a string. The argument name -- store_info_view_model -- is how we'll grab it in the template.
Quick tip: adding a
_view_modelsuffix to your argument names makes templates way easier to read. You immediately know what's a view model versus other injected data. Not required, but helpful when debugging.
The template
<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Acme\Store\ViewModel\StoreInfo $storeInfoViewModel */
/** @var \Magento\Framework\Escaper $escaper */
$storeInfoViewModel = $block->getData('store_info_view_model');
?>
<div class="store-info">
<p><?= $escaper->escapeHtml($storeInfoViewModel->getStoreName()) ?></p>
<p><?= $escaper->escapeHtml($storeInfoViewModel->getStorePhone()) ?></p>
</div>Those @var annotations at the top aren't just decoration -- they're what let your IDE understand the types. With them, you can command-click on getStoreName() and jump straight to the method. Without them, you're guessing.
We're also using $escaper->escapeHtml() on all output. This prevents XSS attacks. Magento's security scanners flag unescaped output, and so should your code reviews.
Enable and flush the cache to apply the updates:
bin/magento module:enable Acme_Store
bin/magento cache:flushNote that you do not need setup:upgrade needed here, since we don't have any database scripts to execute.
This pattern handles probably 99% of your needs. View model for data, layout XML to wire it, template to render. In this case, there's no reason to reach for a block class.
The 1% (when you actually need a block class)
So when do you legitimately need a custom block class?
There's really only one scenario: when you need to talk to the layout system.
View models are isolated from the layout on purpose. They can't check if another block exists, access parent or child blocks, or see what's about to render.
This isolation is what makes them fast.
Block classes have access to getLayout(), which lets you do things like:
- Check if a specific block exists
- Get references to other blocks on the page
- Look inside containers to see their children
- Add or remove blocks programmatically
Here's a real example. Say we need a div that changes its CSS class depending on whether our store-info block is present:
<?php
declare(strict_types=1);
namespace Acme\Store\Block;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\View\Element\Template;
class LayoutDetector extends Template
{
public function hasStoreInfo(): bool
{
try {
return $this->getLayout()->isBlock('acme.store.store-info');
} catch (LocalizedException) {
return false;
}
}
public function getContainerClass(): string
{
return $this->hasStoreInfo() ? 'has-store-info' : 'no-store-info';
}
}That $this->getLayout()->isBlock() call is exactly what a view model can't do. We're asking the layout directly whether a specific block exists.
The layout XML references our custom class:
<block name="acme.store.layout-detector"
class="Acme\Store\Block\LayoutDetector"
template="Acme_Store::layout-detector.phtml"/>When you omit the class attribute, Magento uses the base Template class. When you specify one, it instantiates yours instead.
<?php
/** @var \Acme\Store\Block\LayoutDetector $block */
/** @var \Magento\Framework\Escaper $escaper */
?>
<div class="<?= $escaper->escapeHtmlAttr($block->getContainerClass()) ?>">
<?= __('Hello world!') ?>
</div>Now $block is our LayoutDetector instance, and we can call its methods.
Watch out for caching
One thing to keep in mind when doing layout introspection -- caching.
Our example is cache-safe because the layout structure is static. That block either exists in the XML or it doesn't, so it shows the same result for every visitor.
But if you were checking something user-specific -- like "does this customer have items in their cart?"
Then you'd have a caching problem, because layout introspection happens at build time, not runtime.
The takeaway
Here's the simple version:
- Need data in a template? Use a ViewModel.
- Need to interact with the layout? Use a Block Class.
- Business logic in templates? Never. Pull it out.
You'll see block classes all over Magento core doing things view models could easily handle. Be sure not to take that as guidance, as most of that code was written before view models existed and just hasn't been cleaned up yet.
For new code, view models are the default. Block classes are the exception.
If you want to go deeper on the view layer and the rest of Magento's architecture, check out the Magento 2 Coding Kickstart course.
Module structure reference
app/code/Acme/Store/
├── registration.php
├── etc/
│ └── module.xml
├── Block/
│ └── LayoutDetector.php
├── ViewModel/
│ └── StoreInfo.php
└── view/
└── frontend/
├── layout/
│ └── cms_index_index.xml
└── templates/
├── store-info.phtml
└── layout-detector.phtml
Next steps
- Jumpstart into Magento with the free course (15,000+ students)
- Join the University and get all-inclusive access (500+ students)
- Browse the courses and level up your Magento skills



