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

ViewModel für den Punktestand auf der Kontoseite

ViewModel für den Punktestand auf der Kontoseite

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

Kapitel 47 hat die Entscheidung erklärt - dieses Kapitel baut den ersten echten ViewModel dieses Blocks. PointsBalance zeigt Punktestand und Treue-Stufe an und wird gleich zweimal wiederverwendet: als eigenständiges Widget (dieses Kapitel, verdrahtet in Kapitel 51) und ein zweites Mal im Kundenkonto-Dashboard (Kapitel 52) - ohne eine einzige Zeile Code zu duplizieren.

Die ViewModel-Klasse

app/code/Mironsoft/Loyalty/ViewModel/PointsBalance.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\ViewModel;

use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Customer\Api\Data\CustomerInterface;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\View\Element\Block\ArgumentInterface;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Mironsoft\Loyalty\Model\Util\PointsFormatter;

/**
 * Exposes the logged-in customer's points balance and loyalty tier to the storefront.
 * Deliberately reusable across two independent layout locations (chapter 51's
 * dedicated widget, chapter 52's account dashboard) - a plain PHP object with no
 * Template\Context coupling can be wired into any number of blocks unmodified,
 * exactly the point made in chapter 47.
 */
class PointsBalance implements ArgumentInterface
{
    /**
     * Lazily loaded and cached for the lifetime of this instance, since a template
     * commonly calls several getters (balance, tier, formatted balance) per request.
     *
     * @var CustomerInterface|null
     */
    private ?CustomerInterface $customer = null;

    /**
     * @param CustomerSession $customerSession Provides the logged-in customer ID.
     * @param CustomerRepositoryInterface $customerRepository Loads the customer's custom attributes (chapter 21).
     * @param LoyaltyConfig $loyaltyConfig Confirms the loyalty program is enabled before showing anything.
     */
    public function __construct(
        private readonly CustomerSession $customerSession,
        private readonly CustomerRepositoryInterface $customerRepository,
        private readonly LoyaltyConfig $loyaltyConfig,
    ) {
    }

    /**
     * Whether the balance widget should render at all: the loyalty program must be
     * enabled for the current website (chapter 7) AND the visitor must be logged in.
     *
     * @return bool
     */
    public function canShow(): bool
    {
        return $this->loyaltyConfig->isEnabled() && $this->customerSession->isLoggedIn();
    }

    /**
     * Returns the raw points balance, 0 for a guest or a customer with no history yet.
     *
     * @return int
     */
    public function getPointsBalance(): int
    {
        $attribute = $this->getCustomer()?->getCustomAttribute('loyalty_points_balance');

        return $attribute !== null ? (int) $attribute->getValue() : 0;
    }

    /**
     * Returns the points balance formatted with a thousands separator, e.g. "1.250".
     *
     * @return string
     */
    public function getFormattedPointsBalance(): string
    {
        return PointsFormatter::formatPoints($this->getPointsBalance());
    }

    /**
     * Returns the customer's current loyalty tier, defaulting to bronze if unset.
     *
     * @return string
     */
    public function getTier(): string
    {
        $attribute = $this->getCustomer()?->getCustomAttribute('loyalty_tier');

        return $attribute !== null ? (string) $attribute->getValue() : 'bronze';
    }

    /**
     * Loads and caches the current customer via the repository - never via
     * CustomerSession::getCustomer(), which returns a legacy \Magento\Customer\Model\Customer
     * instance whose getData()/setData() cannot see EAV custom attributes reliably
     * (chapter 30 explains getCustomAttribute() vs. getData() in full).
     *
     * @return CustomerInterface|null Null for a guest or if the customer record vanished mid-session.
     */
    private function getCustomer(): ?CustomerInterface
    {
        if ($this->customer !== null) {
            return $this->customer;
        }

        if (!$this->customerSession->isLoggedIn()) {
            return null;
        }

        try {
            $this->customer = $this->customerRepository->getById(
                (int) $this->customerSession->getCustomerId()
            );
        } catch (NoSuchEntityException|LocalizedException) {
            return null;
        }

        return $this->customer;
    }
}

Keine di.xml-Präferenz nötig

Anders als ein Service Contract (Kapitel 6) braucht ArgumentInterface keinen <preference>-Eintrag - es ist ein reines Marker-Interface ohne eigene Methoden, das dem Layout-XML-Parser nur signalisiert, dass diese Klasse als view_model-Argument erlaubt ist. Die eigentliche Objekterzeugung läuft über den ganz normalen Object Manager mit Constructor Injection, ausgelöst durch das <argument name="view_model" xsi:type="object">-Tag im Layout - vollständig in Kapitel 51.

Tipp: Ein kurzer Vorgeschmack auf die Layout-Verdrahtung, damit dieser ViewModel nicht isoliert wirkt:

<block class="Magento\Framework\View\Element\Template"
       name="loyalty.points.balance"
       template="Mironsoft_Loyalty::widget/points-balance.phtml">
    <arguments>
        <argument name="view_model" xsi:type="object">Mironsoft\Loyalty\ViewModel\PointsBalance</argument>
    </arguments>
</block>

Im Template dann schlicht: $viewModel = $block->getViewModel();, gefolgt von $viewModel->canShow() als Guard-Klausel - Kapitel 53 zeigt das vollständige Template.

Achtung: canShow() vor jedem Rendering-Zweig im Template zu prüfen ist kein optionales Detail: Wird der Punktestand ohne diese Prüfung angezeigt, sieht ein Kunde auf einem Website, auf dem das Programm gerade deaktiviert wurde (Kapitel 7, mironsoft_loyalty/general/enabled), trotzdem einen (veralteten oder irreführenden) Punktestand - genau der Konfigurationswert, den bereits der Router aus Kapitel 46 respektiert.

Tipp: getCustomer() lädt den Kunden maximal einmal pro Request und Instanz - ruft das Template getPointsBalance() und getTier() beide auf, feuert CustomerRepositoryInterface::getById() nur einmal. Ein kleines, aber lohnendes Detail, sobald ein ViewModel mehrfach pro Seite aufgerufen wird, wie es in Kapitel 52 der Fall sein wird.