Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Ein "Meine Punkte"-Widget für CMS-Seiten bauen

Ein "Meine Punkte"-Widget für CMS-Seiten bauen

~7 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026

Kapitel 47 hat es bereits vorausgesagt: Widgets sind einer der drei Orte, an denen Magentos eigene Kernkonventionen eine echte Block-Unterklasse erzwingen, obwohl dieses Modul sonst konsequent ViewModels bevorzugt. Dieses Kapitel löst das konkret ein - und zeigt gleichzeitig, wie man trotzdem keine einzige Zeile Geschäftslogik dupliziert.

Warum hier kein ViewModel reicht

Das Muster aus Kapitel 51 - ein generischer Magento\Framework\View\Element\Template-Block mit view_model-Argument im Layout-XML - funktioniert nur, weil eine Layout-XML-Datei existiert, die diese Verdrahtung explizit deklariert. Sowohl die Widget-Instance-Verwaltung (Magento\Widget\Model\Widget\Instance) als auch der {{widget}}-Direktiven-Filter aus Kapitel 55 instanziieren die in widget.xml angegebene Klasse jedoch direkt über den Object Manager und rufen sofort toHtml() darauf auf - ohne Umweg über eine eigene Layout-XML-Datei und ohne view_model-Argument-Konzept. Die Klasse muss selbst der Block sein. Genau dafür markiert das leere \Magento\Widget\Block\BlockInterface eine Klasse als widget-tauglich - technisch ein reines Marker-Interface, exakt wie ArgumentInterface aus Kapitel 48, nur für einen anderen Zweck.

Die Widget-Klasse

app/code/Mironsoft/Loyalty/Model/Widget/PointsBalanceWidget.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Widget;

use Magento\Framework\View\Element\Template;
use Magento\Framework\View\Element\Template\Context;
use Magento\Widget\Block\BlockInterface;
use Mironsoft\Loyalty\ViewModel\PointsBalance;

/**
 * Renders the "My Points" widget insertable into any CMS WYSIWYG content. Magento's widget
 * subsystem (widget.xml, the {{widget}} directive filter) instantiates this class directly
 * as a layout block - there is no view_model-argument equivalent here, so unlike every other
 * template data source in this module (chapter 47), a real block subclass is unavoidable.
 * The class still delegates every piece of actual logic to ViewModel\PointsBalance (chapter
 * 48) instead of duplicating it - only the mandatory Template/BlockInterface wrapper is new.
 */
class PointsBalanceWidget extends Template implements BlockInterface
{
    /**
     * @param Context $context Framework template context, required by the parent Template class.
     * @param PointsBalance $pointsBalanceViewModel Same view model already used in chapters 48/51/52 - reused unmodified here.
     * @param array<string, mixed> $data Additional block data, populated by Magento's widget system from the admin-configured parameters (chapter 57).
     */
    public function __construct(
        Context $context,
        private readonly PointsBalance $pointsBalanceViewModel,
        array $data = [],
    ) {
        parent::__construct($context, $data);
        $this->setTemplate('Mironsoft_Loyalty::widget/points-balance.phtml');
    }

    /**
     * Returns the injected view model. Exists purely so the template written in chapter 53 -
     * which calls $block->getViewModel() - keeps working here completely unmodified, even
     * though $block is now this widget class instead of a generic Template block.
     *
     * @return PointsBalance
     */
    public function getViewModel(): PointsBalance
    {
        return $this->pointsBalanceViewModel;
    }
}

Genau eine neue Methode: getViewModel(). Der Rest ist reine Verdrahtung des Pflicht-Template-Konstruktors. Weil widget/points-balance.phtml aus Kapitel 53 bereits $block->getViewModel() aufruft, funktioniert genau dieselbe Template-Datei hier unverändert weiter - keine neue .phtml-Datei, keine duplizierte Business-Logik, nur der schmale BlockInterface-Wrapper, den Magentos Widget-System technisch verlangt.

Minimale widget.xml-Registrierung

app/code/Mironsoft/Loyalty/etc/widget.xml
<?xml version="1.0"?>
<widgets xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Widget:etc/widget.xsd">
    <widget id="mironsoft_loyalty_points_balance"
            class="Mironsoft\Loyalty\Model\Widget\PointsBalanceWidget"
            is_email_compatible="false">
        <label translate="true">Loyalty: My Points Balance</label>
        <description translate="true">Displays the logged-in customer's points balance and loyalty tier.</description>
        <parameters>
            <!-- chapter 57 adds show_tier_badge and css_class here -->
        </parameters>
    </widget>
</widgets>

Tipp: Wie bei ArgumentInterface in Kapitel 48 ist auch hier keine di.xml-Präferenz nötig - widget.xml ist die vollständige, eigenständige Registrierung. Nach dem Anlegen: bin/cache-clean config, damit das Widget im Admin unter Content > Elements > Widgets auftaucht (Kapitel 57 zeigt die Admin-Bedienung im Detail).

Achtung: Wo dieses Widget landet, ist keine beliebige Geschmacksfrage. Auf der bereits nicht-cachebaren Kontoseite (Kapitel 52, sitzungsgebunden) ist alles unkritisch. Wird dasselbe Widget aber per {{widget}}-Direktive in eine öffentliche, gewöhnlich vollständig gecachte CMS-Landingpage eingefügt, entsteht ein echtes Datenleck: Der Widget-Filter aus Kapitel 55 läuft, BEVOR Magentos Full-Page-Cache die fertige Seite als HTML einfriert - der Punktestand des ERSTEN Besuchers nach einer Cache-Invalidierung würde für alle nachfolgenden Besucher derselben Seite im Cache eingefroren bleiben, bis der Cache erneut invalidiert wird. Für öffentliche, cachebare Seiten ist Kapitel 84 (Customer Section Data, AJAX-basiert und FPC-sicher) der richtige Mechanismus, nicht dieses Widget. Kapitel 61 fasst diese Abwägung am Ende dieses Blocks noch einmal explizit zusammen.

Tipp: PointsBalance::canShow() (Kapitel 48) schützt weiterhin zuverlässig gegen Gäste und ein deaktiviertes Programm - das gilt unverändert für alle drei Einsatzorte dieses ViewModels: Widget (hier), Kontodashboard (Kapitel 52) und, mit dem in der Warnung oben genannten Cache-Vorbehalt, jede weitere CMS-Seite.