ViewModel Pattern in Magento 2: Saubere phtml-Templates ohne aufgeblähte Block-Klassen
· Lesezeit: ca. 12 Minuten · Teil der Serie: Design Patterns in Magento 2
ViewModel Pattern
in Magento 2
ArgumentInterface implementieren, Logik aus Block-Klassen auslagern, via Layout-XML injizieren, unit-testen und im Hyvä Theme optimal einsetzen – vollständig erklärt.
ViewModel Pattern: Warum Block-Klassen allein nicht reichen
Magento 2 hat von Anfang an Block-Klassen als Brücke zwischen PHP-Logik und phtml-Templates vorgesehen. In der Theorie: Block liefert Daten, Template rendert HTML. In der Praxis: Block-Klassen wurden zu Sammelbecken für alles mögliche – Datenbankabfragen, Formatierungslogik, Konfigurationszugriffe, HTTP-Session-Manipulation. Das Ergebnis war schwer testbarer, schwer wartbarer Code.
Das ViewModel Pattern ist die saubere Lösung. Seit Magento 2.2 steht das ArgumentInterface zur Verfügung – ein schlichtes Marker-Interface, das es erlaubt, beliebige PHP-Klassen als Argument an einen Block zu übergeben. Diese Klassen heißen ViewModels und kapseln die Präsentationslogik ohne irgendeine Magento-Basisklassen-Abhängigkeit.
1. Das Problem mit aufgeblähten Block-Klassen
Schauen wir uns eine typische Block-Klasse an, wie sie leider oft in Legacy-Magento-Code vorkommt:
<?php
// PROBLEM: Aufgeblähte Block-Klasse — alles in einem
class ProductInfoBlock extends \Magento\Catalog\Block\Product\View
{
// Geschäftslogik direkt im Block
public function getDiscountPercent(): int
{
$product = $this->getProduct();
$regular = $product->getPrice();
$final = $product->getFinalPrice();
return $regular > 0 ? (int) round((1 - $final / $regular) * 100) : 0;
}
// Formatierungslogik im Block
public function getFormattedPrice(): string
{
return '€ ' . number_format($this->getProduct()->getFinalPrice(), 2, ',', '.');
}
// Config-Zugriff im Block
public function isBadgeEnabled(): bool
{
return (bool) $this->_scopeConfig->getValue('catalog/badge/enabled');
}
// Repository-Aufruf im Block
public function getRelatedBlogPosts(): array
{
return $this->postRepository->getByProductId($this->getProduct()->getId());
}
}
// PROBLEME:
// 1. Erbt von komplexer Basisklasse → schwer mockbar in Tests
// 2. Gemischte Verantwortlichkeiten (Logik + Rendering + Datenzugriff)
// 3. Eng an Magento-Framework gebunden
// 4. Kaum unit-testbar
2. ViewModel erstellen: ArgumentInterface implementieren
Ein ViewModel ist eine PHP-Klasse, die ausschließlich Magento\Framework\View\Element\Block\ArgumentInterface implementiert. Dieses Interface hat keine Methoden – es ist ein Marker-Interface, das Magento mitteilt, dass die Klasse als Block-Argument injiziert werden darf.
<?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;
}
}
Vorteile gegenüber Block-Klasse:
- Keine Magento-Basisklassen-Abhängigkeit → leicht mockbar
- Klare, einzelne Verantwortlichkeit: Präsentationslogik für Produktdetailseite
- Constructor Property Promotion: sauber, kompakt
- Via DI konfigurierbar, via di.xml austauschbar
3. ViewModel via Layout-XML injizieren
Der ViewModel wird als Argument an den Block übergeben – konfiguriert in der Layout-XML. Es wird kein PHP-Code im Block oder Template benötigt, um den ViewModel zu instanziieren.
<!-- 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. ViewModel im phtml-Template verwenden
Im Template wird der ViewModel über $block->getData('view_model') abgerufen. Das ist der einzige Punkt, an dem Block und ViewModel verbunden werden – ohne PHP-Logik im Template.
<?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">Verwandte Blogartikel</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. Mehrere ViewModels pro Block
Ein Block kann mehrere ViewModels haben – ein ViewModels für verschiedene Verantwortlichkeiten. Das hält ViewModels klein und fokussiert (Single Responsibility Principle).
<!-- Mehrere ViewModels via Layout-XML -->
<block class="Magento\Framework\View\Element\Template"
name="mironsoft.product.detail"
template="Mironsoft_Catalog::product/detail.phtml">
<arguments>
<!-- ViewModel für Preis/Badge-Logik -->
<argument name="pricing_view_model" xsi:type="object">
Mironsoft\Catalog\ViewModel\ProductPricingViewModel
</argument>
<!-- ViewModel für Social Sharing -->
<argument name="social_view_model" xsi:type="object">
Mironsoft\SocialShare\ViewModel\SocialShareViewModel
</argument>
<!-- ViewModel für Bewertungen -->
<argument name="reviews_view_model" xsi:type="object">
Mironsoft\Reviews\ViewModel\ProductReviewsViewModel
</argument>
</arguments>
</block>
<?php
// Im Template: jeden ViewModel separat abrufen
/** @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 für ViewModels – einfach und schnell
Der größte Vorteil des ViewModel Patterns ist die einfache Testbarkeit. Da ViewModels keine Magento-Basisklassen erben, lassen sie sich mit Standard-PHPUnit ohne Magento-Bootstrap testen.
<?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;
}
}
Diese Tests laufen in Millisekunden, brauchen keine Datenbankverbindung und keinen Magento-Bootstrap. Das ist der fundamentale Unterschied zu Tests für Block-Klassen, die auf den vollständigen Magento-Bootstrap angewiesen sind.
7. ViewModel im Hyvä Theme
Im Hyvä Theme ist das ViewModel Pattern noch wichtiger als in Luma. Da Hyvä konsequent auf saubere phtml-Templates mit Alpine.js setzt, braucht man eine klare Trennlinie zwischen PHP-Datenlieferung (ViewModel) und JavaScript-Interaktivität (Alpine.js).
<?php
/**
* Hyvä Theme Template: product/badge.phtml
* ViewModel liefert PHP-Daten, Alpine.js übernimmt die Interaktivität.
*
* @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);
// PHP-Daten für Alpine.js vorbereiten — immer escapen!
$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">NEU</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() ?>
Das Muster: PHP (via ViewModel) liefert die initiale Daten, Alpine.js rendert sie reaktiv. Keine PHP-Logik im Template, keine Magento-Klassen in Alpine.js.
8. Anti-Patterns beim ViewModel Pattern
<?php
// ANTI-PATTERN 1: Rendering-Logik im ViewModel
class BadViewModel implements ArgumentInterface
{
// ViewModels sollten KEIN HTML ausgeben
public function renderBadge(ProductInterface $product): string
{
return '<span class="badge">-' . $this->getDiscount($product) . '%</span>';
// HTML gehört ins Template, nicht in PHP-Klassen
}
}
// ANTI-PATTERN 2: Block-Referenz im ViewModel
class AnotherBadViewModel implements ArgumentInterface
{
public function __construct(
private readonly \Magento\Framework\View\Element\Template $block // FALSCH!
) {}
// ViewModel sollte den Block nicht kennen — das ist zirkulär
}
// ANTI-PATTERN 3: Session-Zugriff im ViewModel ohne Proxy
class AlsoBadViewModel implements ArgumentInterface
{
public function __construct(
private readonly \Magento\Customer\Model\Session $session // Ohne Proxy!
) {}
// Session muss als Proxy injiziert werden
}
// RICHTIG:
class GoodViewModel implements ArgumentInterface
{
public function __construct(
private readonly \Magento\Customer\Model\Session\Proxy $session // Mit Proxy!
) {}
}
Mironsoft
Hyvä Theme & Magento Clean Code
Magento-Code refactoren und modernisieren?
Block-Klassen zu ViewModels refactoren, Legacy-Code bereinigen, PHPUnit-Tests einführen und Magento-Module auf Clean-Code-Standard bringen.
9. Zusammenfassung
Das ViewModel Pattern ist der sauberste Weg, Präsentationslogik in Magento 2 zu kapseln. ArgumentInterface implementieren, via Layout-XML injizieren, im Template abrufen. Das Ergebnis: testbarer, wartbarer Code ohne Magento-Framework-Ballast in der Logikschicht.
ViewModel Pattern in Magento 2 – Regeln auf einen Blick
ArgumentInterface implementieren
Kein Erben von Magento-Klassen. Nur ArgumentInterface implementieren. Constructor Property Promotion nutzen. Kein HTML im ViewModel.
Layout-XML Injektion
xsi:type="object" als Argument. Name view_model oder beschreibender Name. Im Template via $block->getData('view_model').
Unit-Tests
Standard PHPUnit ohne Magento-Bootstrap. Alle Dependencies als Mocks. Schnell, isoliert, zuverlässig. Kein ObjectManager in Tests nötig.
Hyvä Theme
PHP-Daten via ViewModel, Alpine.js für Interaktivität. Daten als JSON an Alpine übergeben via json_encode() + escapeHtmlAttr(). Klare Trennung.
10. FAQ: ViewModel Pattern in Magento 2
1 ViewModel vs. Block – was ist der Unterschied?
ArgumentInterface, keine Magento-Abhängigkeit, reine Logik. Moderne Empfehlung: Block für Template + Rendering, ViewModel für alle Logik und Datenzugriffe.2 Wie injiziere ich einen ViewModel in mehreren Blocks?
<argument name="view_model" xsi:type="object">VendorViewModel</argument>. Da der ViewModel ein Singleton (Shared) ist, wird dieselbe Instanz geteilt – kein Performance-Problem.3 Kann ich ViewModels cachen?
private ?array $cache = null;), um teure Operationen (DB-Queries) nur einmal auszuführen.4 ViewModel vs. Helper – welchen soll ich verwenden?
ArgumentInterface, keine Basisklasse, einfach testbar. Neue Module: immer ViewModels. Bestehende Helpers: sukzessive zu ViewModels refactoren.5 Kann ein ViewModel auf die Customer Session zugreifen?
Magento\Customer\Model\Session\Proxy injizieren. Ohne Proxy wird die Session zu früh initialisiert und kann Page-Cache-Probleme verursachen. Gilt für alle Klassen, die lazy geladen werden sollen.6 Wie teste ich einen ViewModel mit PHPUnit?
setUp() instanziieren mit gemockten Dependencies — kein Magento-Bootstrap nötig. Methoden direkt aufrufen und prüfen. Tests laufen in Millisekunden. Der größte Vorteil gegenüber Block-Tests, die den vollen Magento-Bootstrap benötigen.7 Wie verwende ich ViewModels im Hyvä Theme ohne eigene Block-Klasse?
Magento\Framework\View\Element\Template setzen (kein eigener Block nötig) und ViewModel via Layout-XML injizieren. Im Template: $viewModel = $block->getData('view_model'). Daten als JSON an Alpine.js: x-data="fn(= escapeHtmlAttr(json_encode($data)) ?>)".