View Models
A view model is a plain class implementing ArgumentInterface and handed to a template through a layout argument.
<argument name="view_model" xsi:type="object">Vendor\Module\ViewModel\Thing</argument>A view model is a plain PHP class that supplies data to a template. It implements Magento\Framework\View\Element\Block\ArgumentInterface, a marker interface that declares no methods at all. The interface exists as a gate: layout XML will only inject an object argument whose class implements it.
Why it is preferred over a custom block
The docblock on Magento\Framework\View\Element\Template is explicit: "Avoid extending this class. If you need custom presentation logic in your blocks, use this class as block, and declare custom view models in block arguments in layout handle file." A view model extends nothing. It has no inherited data object, no cache plumbing, no large parent constructor. Its public API is only what you wrote, which makes it readable and unit testable.
How it reaches the template
Declare the generic Template block in layout, give it your template, and add an object argument. In the .phtml, $block->getViewModel() returns the instance. The instance is shared by default; add shared="false" to the argument when each block needs its own.
What trips people up
A new class needs bin/magento setup:di:compile in production mode, and the layout edit needs bin/magento cache:flush. A view model is created by dependency injection, so it cannot take request data through its constructor; pass that in as a method argument from the template.
Example
<?php
// app/code/Vendor/Notice/ViewModel/NewArrivals.php
declare(strict_types=1);
namespace Vendor\Notice\ViewModel;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\Product\Attribute\Source\Status;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\View\Element\Block\ArgumentInterface;
class NewArrivals implements ArgumentInterface
{
private ProductRepositoryInterface $productRepository;
private SearchCriteriaBuilder $searchCriteriaBuilder;
public function __construct(
ProductRepositoryInterface $productRepository,
SearchCriteriaBuilder $searchCriteriaBuilder
) {
$this->productRepository = $productRepository;
$this->searchCriteriaBuilder = $searchCriteriaBuilder;
}
/**
* @return ProductInterface[]
*/
public function getProducts(int $limit = 4): array
{
$criteria = $this->searchCriteriaBuilder
->addFilter(ProductInterface::STATUS, Status::STATUS_ENABLED)
->setPageSize($limit)
->create();
return $this->productRepository->getList($criteria)->getItems();
}
}
// The storefront shows nothing until layout passes this class to a template.
// The filter value goes through addFilter, which binds it, so nothing is
// concatenated into SQL.When to use it
- Fetch a short product list for a homepage widget without writing a block class.
- Format a price or a date for one template, kept out of the .phtml.
- Expose a store configuration value to an existing core template through a layout argument.
- Make presentation logic unit testable by keeping it in a class with no framework parent.
More examples
Passing the view model in through layout
No custom block class exists here. The framework's generic Template block renders the file, and the object argument is constructed by dependency injection with its own constructor arguments resolved. The cms_index_index handle limits it to the CMS home page. Run bin/magento cache:flush to pick up the new layout file.
<!-- app/code/Vendor/Notice/view/frontend/layout/cms_index_index.xml -->
<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="vendor.new.arrivals"
template="Vendor_Notice::new-arrivals.phtml">
<arguments>
<argument name="view_model" xsi:type="object">Vendor\Notice\ViewModel\NewArrivals</argument>
<argument name="heading" xsi:type="string" translate="true">New arrivals</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>Reading it in the template
$block->getViewModel() and $block->getHeading() both read layout arguments through the data object getter, which is why the argument names are view_model and heading. Assigning the view model to a local variable with a @var docblock gives you autocompletion and keeps the loop short. getProductUrl() returns a URL, so it takes escapeUrl rather than escapeHtml.
<?php
// app/code/Vendor/Notice/view/frontend/templates/new-arrivals.phtml
/** @var Magento\Framework\View\Element\Template $block */
/** @var Magento\Framework\Escaper $escaper */
/** @var Vendor\Notice\ViewModel\NewArrivals $viewModel */
$viewModel = $block->getViewModel();
$products = $viewModel->getProducts(4);
?>
<?php if ($products): ?>
<h2><?= $escaper->escapeHtml($block->getHeading()) ?></h2>
<ul>
<?php foreach ($products as $product): ?>
<li>
<a href="<?= $escaper->escapeUrl($product->getProductUrl()) ?>">
<?= $escaper->escapeHtml($product->getName()) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>When each block needs its own instance
Object arguments resolve to a shared instance, so two blocks on the same page receive the same view model and any state it caches internally is shared between them. shared="false" gives this block a fresh instance. Reach for it only when the view model keeps state; a stateless view model is cheaper shared. A stateless view model is also the safer default, because shared state that varies per block is a classic source of a value appearing in the wrong place.
<!-- app/code/Vendor/Notice/view/frontend/layout/catalog_category_view.xml -->
<block class="Magento\Framework\View\Element\Template"
name="vendor.arrivals.sidebar"
template="Vendor_Notice::new-arrivals.phtml">
<arguments>
<argument name="view_model" xsi:type="object" shared="false">Vendor\Notice\ViewModel\NewArrivals</argument>
<argument name="heading" xsi:type="string" translate="true">Just in</argument>
</arguments>
</block>
Discussion