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

A View Model for the Points Balance on the Account Page

A View Model for the Points Balance on the Account Page

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

Chapter 47 explained the decision - this chapter builds this block's first real view model. PointsBalance shows the points balance and loyalty tier, and gets reused twice right away: as a standalone widget (this chapter, wired in chapter 51) and a second time on the customer account dashboard (chapter 52) - without duplicating a single line of code.

The view model class

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 reliably see EAV custom attributes
     * (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;
    }
}

No di.xml preference needed

Unlike a service contract (chapter 6), ArgumentInterface needs no <preference> entry - it's a pure marker interface with no methods of its own, only signalling to the layout XML parser that this class is allowed as a view_model argument. The actual object creation runs through the ordinary object manager with constructor injection, triggered by the <argument name="view_model" xsi:type="object"> tag in the layout - fully covered in chapter 51.

Tipp: A quick preview of the layout wiring so this view model doesn't feel isolated:

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

In the template it's then simply: $viewModel = $block->getViewModel();, followed by $viewModel->canShow() as a guard clause - chapter 53 shows the complete template.

Achtung: Checking canShow() before every rendering branch in the template isn't an optional detail: without that check, a customer on a website where the program has just been disabled (chapter 7, mironsoft_loyalty/general/enabled) would still see a (stale or misleading) points balance - exactly the config value the router from chapter 46 already respects.

Tipp: getCustomer() loads the customer at most once per request and instance - if the template calls both getPointsBalance() and getTier(), CustomerRepositoryInterface::getById() fires only once. A small but worthwhile detail once a view model gets called several times per page, as will be the case in chapter 52.