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

Building a "My Points" Widget for CMS Pages

Building a "My Points" Widget for CMS Pages

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

Chapter 47 already predicted this: widgets are one of the three places where Magento's own core conventions force a real block subclass, even though this module otherwise consistently prefers view models. This chapter cashes that prediction in - while still duplicating not a single line of business logic.

Why no view model is enough here

The pattern from chapter 51 - a generic Magento\Framework\View\Element\Template block with a view_model argument in layout XML - only works because a layout XML file exists that explicitly declares that wiring. Both widget instance management (Magento\Widget\Model\Widget\Instance) and the {{widget}} directive filter from chapter 55 instantiate the class named in widget.xml directly through the object manager and immediately call toHtml() on it - no detour through a layout XML file of its own, no view_model argument concept. The class has to BE the block. That's exactly what the empty \Magento\Widget\Block\BlockInterface marks a class as - technically a pure marker interface, exactly like ArgumentInterface from chapter 48, just for a different purpose.

The widget class

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;
    }
}

Exactly one new method: getViewModel(). Everything else is pure wiring for the mandatory Template constructor. Because widget/points-balance.phtml from chapter 53 already calls $block->getViewModel(), that exact same template file keeps working here completely unmodified - no new .phtml file, no duplicated business logic, just the thin BlockInterface wrapper Magento's widget system technically requires.

Minimal widget.xml registration

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: Just like ArgumentInterface in chapter 48, no di.xml preference is needed here either - widget.xml is the complete, standalone registration. After adding it: bin/cache-clean config, so the widget shows up in the admin under Content > Elements > Widgets (chapter 57 covers the admin workflow in detail).

Achtung: Where this widget ends up is not a matter of taste. On the already non-cacheable account page (chapter 52, session-bound), nothing about this is a problem. But inserting the same widget via the {{widget}} directive into a public, normally fully-cached CMS landing page creates a real data leak: the widget filter from chapter 55 runs BEFORE Magento's full page cache freezes the finished page as HTML - the first visitor's points balance after a cache invalidation would stay frozen in the cache for every subsequent visitor of that same page, until the cache is invalidated again. For public, cacheable pages, chapter 84 (customer section data, AJAX-based and FPC-safe) is the right mechanism, not this widget. Chapter 61 sums this trade-off up explicitly again at the end of this block.

Tipp: PointsBalance::canShow() (chapter 48) still reliably guards against guests and a disabled program - unchanged across all three places this view model is used: the widget (here), the account dashboard (chapter 52), and, with the cache caveat from the warning above, any further CMS page.