Attribute Backend Models: Automatic Calculation on Save
Attribute Backend Models: Automatic Calculation on Save
~7 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Chapter 21 created loyalty_tier as an ordinary, manually filled dropdown attribute - since then, the point balance in loyalty_points_balance and the tier derived from it can drift apart independently, the moment someone edits only one of the two fields directly (admin form, REST call, data import). This chapter closes exactly that gap with a backend model that automatically recalculates loyalty_tier from loyalty_points_balance on every save - with no observer and no plugin involved.
The third option besides observer and plugin
Block 4 and block 5 explain in detail when an observer (chapters 29-31) and when a plugin (chapters 38-39) is the right choice - chapter 37 later even gives an explicit decision guide for it. A backend model is a third, often overlooked option that exists exclusively for EAV attributes: it isn't tied to an event name or a specific target class, but sits directly on the attribute itself - active wherever the entity is saved, whether through the admin form, the REST API, or a programmatic $customerRepository->save() call.
The methods of BackendInterface
\Magento\Eav\Model\Entity\Attribute\Backend\AbstractBackend implements BackendInterface and offers five hook points a custom subclass can selectively override:
beforeSave($object)- runs immediately before the actual INSERT/UPDATE, the classic place for automatic calculation like in this chapter.afterSave($object)- runs afterward, e.g. to keep a side table in sync.afterLoad($object)- runs when the entity is loaded, e.g. to prepare a derived display value.validate($object)- custom validation rules beyondrequired/unique; a failing validation throws aLocalizedException.beforeDelete($object)/afterDelete($object)- rarely needed, e.g. to clean up dependent data.
Implementing LoyaltyTierBackend
The new class gets PointsCalculator (chapter 5) and LoyaltyConfig (chapter 7) injected through its constructor - a backend model is created via the object manager like any other object, and therefore supports ordinary dependency injection, not just the option-less constructors known from AbstractSource.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Customer\Attribute\Backend;
use Magento\Eav\Model\Entity\Attribute\Backend\AbstractBackend;
use Mironsoft\Loyalty\Model\Config\LoyaltyConfig;
use Mironsoft\Loyalty\Model\Service\PointsCalculator;
/**
* Backend model for the loyalty_tier customer attribute. Recalculates the tier
* from loyalty_points_balance on every entity save, so the two denormalized
* fields introduced in chapter 21 can never silently drift apart.
*/
class LoyaltyTierBackend extends AbstractBackend
{
/**
* @param PointsCalculator $pointsCalculator Pure business rule for determining a tier from a point total.
* @param LoyaltyConfig $loyaltyConfig Typed reader for the tier_thresholds configuration value.
*/
public function __construct(
private readonly PointsCalculator $pointsCalculator,
private readonly LoyaltyConfig $loyaltyConfig
) {
}
/**
* Overwrites loyalty_tier with a freshly calculated value based on the
* entity's current loyalty_points_balance, immediately before it is saved.
* $object deliberately carries no native type hint - see the warning below
* this listing for why.
*
* @param \Magento\Framework\DataObject $object The entity currently being saved (a Customer model here).
* @return $this
*/
public function beforeSave($object)
{
$pointsBalance = (int) $object->getData('loyalty_points_balance');
$tier = $this->pointsCalculator->determineTier(
$pointsBalance,
$this->loyaltyConfig->getTierThresholdsJson()
);
$object->setData($this->getAttribute()->getAttributeCode(), $tier);
return parent::beforeSave($object);
}
}Achtung: declare(strict_types=1) and the CLAUDE.md project convention normally demand full typing - yet the $object parameter deliberately carries no class hint here. Reason: AbstractBackend::beforeSave($object) itself declares no parameter type in Magento core. PHP doesn't allow an overriding method to narrow a previously untyped parameter (the contravariance rule) - trying to write \Magento\Framework\DataObject $object here ends in a fatal error ("Declaration ... must be compatible with ...") the moment PHP checks the class hierarchy. The return type could be added instead (covariance is allowed), but is deliberately left out here too, because parent::beforeSave($object) also returns without a declared type. Full type information lives in the PHPDoc block instead.
Attaching a backend model via updateAttribute()
Just like the scope change in chapter 25, the backend model is attached afterward, via a data patch, to the already existing loyalty_tier attribute - the underlying database column in eav_attribute is called backend_model, regardless of whether it's set via addAttribute() at creation time or via updateAttribute() afterward.
<?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\Customer\Attribute\Backend\LoyaltyTierBackend;
/**
* Attaches LoyaltyTierBackend to the existing loyalty_tier customer attribute, so
* the tier is recalculated automatically from loyalty_points_balance on every save.
*/
class UpdateCustomerLoyaltyTierBackend implements DataPatchInterface
{
/**
* @param ModuleDataSetupInterface $moduleDataSetup Provides the setup connection for the patch.
* @param EavSetupFactory $eavSetupFactory Creates the EavSetup helper used to update the attribute.
*/
public function __construct(
private readonly ModuleDataSetupInterface $moduleDataSetup,
private readonly EavSetupFactory $eavSetupFactory
) {
}
/**
* Sets backend_model on loyalty_tier to LoyaltyTierBackend::class.
*
* @return void
*/
public function apply(): void
{
$this->moduleDataSetup->getConnection()->startSetup();
/** @var \Magento\Eav\Setup\EavSetup $eavSetup */
$eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
$eavSetup->updateAttribute(
Customer::ENTITY,
'loyalty_tier',
'backend_model',
LoyaltyTierBackend::class
);
$this->moduleDataSetup->getConnection()->endSetup();
}
/**
* @return array<int, string>
*/
public static function getDependencies(): array
{
return [\Mironsoft\Loyalty\Setup\Patch\Data\InstallCustomerLoyaltyAttributes::class];
}
/**
* @return array<int, string>
*/
public function getAliases(): array
{
return [];
}
}bin/magento setup:upgrade
bin/magento indexer:reindex customer_grid
bin/magento cache:flushAchtung: loyalty_tier remains visible as an editable dropdown in the admin form (visible => true, chapter 21) - but any manual selection is silently overwritten on the next save, as soon as loyalty_points_balance is loaded in the same call. Anyone who wants this behavior to be visible would set the field to visible => false and read_only => true via one more updateAttribute() patch - an exercise that follows exactly the same pattern as the scope patch in chapter 25 and isn't repeated here.
Tipp: Using $this->getAttribute()->getAttributeCode() instead of the hardcoded string 'loyalty_tier' might look like unnecessary caution here, since the class is only ever attached to a single attribute anyway - it's still the Magento standard, because it makes the exact same backend model class reusable on a renamed or a second, similar attribute with zero code changes.
A single attribute that keeps itself consistent is now solved. Chapter 27 goes one step further: what happens when two different attributes on two different entities - category bonus and product multiplier - feed into a single calculation together and can contradict each other?