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

View
Model
Design Pattern #5 · Structural / Presentation

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.

⏱ 12 Min. PHP 8.4 Hyvä Theme Clean Code

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.

Block-Refactoring
Aufgeblähte Block-Klassen analysieren und Logik sauber in fokussierte ViewModels auslagern.
Hyvä-Migration
Legacy-Luma-Templates auf Hyvä Theme mit Alpine.js und sauberem ViewModel-Muster migrieren.
PHPUnit-Tests
Unit-Test-Suite für ViewModels ohne Magento-Bootstrap — schnell, isoliert, vollständige Abdeckung.

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?
Block: Magento-Basisklasse, Rendering, Caching, Child-Blocks. ViewModel: nur 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?
In der Layout-XML bei jedem Block als Argument eintragen: <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?
ViewModels sind Singletons (Shared) – sie werden einmal instanziiert und für alle Blocks im Request geteilt. Interne Ergebnisse können via Property-Cache gespeichert werden (private ?array $cache = null;), um teure Operationen (DB-Queries) nur einmal auszuführen.
4 ViewModel vs. Helper – welchen soll ich verwenden?
Helper (AbstractHelper) ist ein veraltetes Magento 1-Konzept, erbt von Magento-Basisklasse, schwer testbar. ViewModel: nur ArgumentInterface, keine Basisklasse, einfach testbar. Neue Module: immer ViewModels. Bestehende Helpers: sukzessive zu ViewModels refactoren.
5 Kann ein ViewModel auf die Customer Session zugreifen?
Ja – aber immer als Proxy: 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?
ViewModel direkt im 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?
Block auf 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()".
8 Kann ich einen ViewModel mit Virtual Types konfigurieren?
Ja — Virtual Type in di.xml erstellen und als ViewModel-Klasse in Layout-XML verwenden. Nützlich wenn dieselbe ViewModel-Klasse mit unterschiedlichen Konfigurationen für verschiedene Blocks benötigt wird. Kein zusätzlicher PHP-Code nötig — nur di.xml-Konfiguration.
9 Darf ein ViewModel andere ViewModels injizieren?
Technisch möglich, aber nicht empfohlen. Deutet auf zu große Verantwortlichkeit hin (verletzt Single Responsibility). Besser: gemeinsamen Service injizieren. Ein ViewModel der andere ViewModels braucht, sollte aufgeteilt werden in mehrere fokussierte ViewModels.
10 Welche Methoden gehören in einen ViewModel und welche nicht?
IN ViewModel: Datenabruf (Config, Repository), Formatierung (Preis, Datum), Berechnungen, Bedingungs-Checks. NICHT im ViewModel: HTML ausgeben, Block-Methoden aufrufen, Redirects, Datenbankschreiboperationen, Session-Manipulation ohne klaren Grund. ViewModel = Datenprovider, nicht Controller.