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