Customer Attribute: Points Balance and Loyalty Tier on the Customer
Customer Attribute: Points Balance and Loyalty Tier on the Customer
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
customer is the third core entity in this block that's already managed as EAV - just like product and category. Two new attributes are added: loyalty_points_balance (type int, default 0) as a fast, directly readable point balance, and loyalty_tier (type varchar, dropdown with the three values bronze/silver/gold) as the current loyalty tier - both are derived from the points ledger (chapters 3-4), but are maintained as a redundant, fast-readable copy directly on the customer so checkout and the account page can display them without an extra ledger query.
Redundancy is deliberate here
loyalty_points_balance is, strictly speaking, derivable from the ledger (SUM(points) of all a customer's entries), but is still maintained as its own attribute here - the same fallacy would be to consider the balance_after field from chapter 3 redundant. Both fields are a deliberate denormalization trade-off: an account overview page that had to aggregate over thousands of ledger rows on every request scales worse than a single SELECT on an EAV attribute. Chapter 30 (block 4) shows the observer that keeps the ledger and the customer attribute in sync on every points transaction.
The setup patch for both attributes
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Setup\Patch\Data;
use Magento\Customer\Model\Customer;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
use Mironsoft\Loyalty\Model\Source\LoyaltyTier;
/**
* Registers loyalty_points_balance and loyalty_tier on the customer entity.
*/
class InstallCustomerLoyaltyAttributes implements DataPatchInterface
{
/**
* @param ModuleDataSetupInterface $moduleDataSetup Provides the setup connection for the patch.
* @param EavSetupFactory $eavSetupFactory Creates the EavSetup helper used to register attributes.
*/
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup,
private readonly EavSetupFactory $eavSetupFactory
) {
}
/**
* Adds the points balance and tier attributes to the existing customer entity.
*
* @return void
*/
public function apply(): void
{
$this->moduleDataSetup->getConnection()->startSetup();
/** @var \Magento\Eav\Setup\EavSetup $eavSetup */
$eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
$eavSetup->addAttribute(Customer::ENTITY, 'loyalty_points_balance', [
'type' => 'int',
'label' => 'Loyalty Points Balance',
'input' => 'text',
'required' => false,
'default' => '0',
'visible' => true,
'system' => false,
'user_defined' => true,
'position' => 200,
]);
$eavSetup->addAttribute(Customer::ENTITY, 'loyalty_tier', [
'type' => 'varchar',
'label' => 'Loyalty Tier',
'input' => 'select',
'source' => LoyaltyTier::class,
'required' => false,
'default' => LoyaltyTier::TIER_BRONZE,
'visible' => true,
'system' => false,
'user_defined' => true,
'position' => 210,
]);
$this->moduleDataSetup->getConnection()->endSetup();
}
/**
* @return array<int, string>
*/
public static function getDependencies(): array
{
return [];
}
/**
* @return array<int, string>
*/
public function getAliases(): array
{
return [];
}
}Achtung: Magento_Customer has been in the module.xml sequence since chapter 2 - nothing to add here. What's easy to overlook, though, is system => false: without this key, Magento sometimes treats new customer attributes as a system attribute, which makes them appear read-only and undeletable in the form editor under Stores > Customer Attributes.
loyalty_tier needs a source model
input => 'select' without source would result in an empty dropdown - without a source model, Magento simply has no idea which options to show. Unlike RewardType in chapter 13, which is only used by a single attribute, LoyaltyTier deliberately doesn't live under Model/Reward/Source/, but under the entity-neutral Model/Source/ - chapter 22 reuses this exact class for loyalty_tier_override on the company entity.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Source;
use Magento\Eav\Model\Entity\Attribute\Source\AbstractSource;
/**
* Source model for the bronze/silver/gold loyalty tier dropdown. Shared between
* the customer attribute loyalty_tier (chapter 21) and the company attribute
* loyalty_tier_override (chapter 22) - see chapter 24 for the full rationale.
*/
class LoyaltyTier extends AbstractSource
{
/**
* @var string
*/
public const TIER_BRONZE = 'bronze';
/**
* @var string
*/
public const TIER_SILVER = 'silver';
/**
* @var string
*/
public const TIER_GOLD = 'gold';
/**
* Returns the dropdown options shown in admin forms and grid filters.
*
* @return array<int, array{value: string, label: string}>
*/
public function getAllOptions(): array
{
if ($this->_options === null) {
$this->_options = [
['value' => self::TIER_BRONZE, 'label' => __('Bronze')],
['value' => self::TIER_SILVER, 'label' => __('Silver')],
['value' => self::TIER_GOLD, 'label' => __('Gold')],
];
}
return $this->_options;
}
}Tipp: The three constants TIER_BRONZE/TIER_SILVER/TIER_GOLD are deliberately spelled identically to the PointsCalculator::TIER_BRONZE constants and friends from chapter 5 - the two classes don't know about each other (the source model is a pure display/form concept, the service is pure calculation logic), but the same string values 'bronze'/'silver'/'gold' guarantee that determineTier() return values can be stored directly as the loyalty_tier attribute value, with no translation table needed.
bin/magento setup:upgrade
bin/magento indexer:reindex customer_grid
bin/magento cache:flushAchtung: A direct $customer->setLoyaltyPointsBalance() call in controller or observer code only works after setup:upgrade and a full object rebuild (a new request, or bin/magento cache:flush) - the magic getter/setter mechanism of AbstractModel reads the available attributes from the EAV cache, which would otherwise still know the old attribute state.
With the product, category, and customer attributes done, the EAV side of block 3 is complete. Chapter 22 deliberately switches technique: company is not an EAV entity, so loyalty_tier_override needs a completely different approach.