Attribut-Backend-Modelle: automatische Berechnung beim Speichern
Attribut-Backend-Modelle: automatische Berechnung beim Speichern
~7 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Kapitel 21 hat loyalty_tier als gewöhnliches, manuell befüllbares Dropdown-Attribut angelegt - seither können der Punktestand in loyalty_points_balance und die daraus abgeleitete Stufe unabhängig voneinander auseinanderlaufen, sobald jemand nur eines der beiden Felder direkt bearbeitet (Admin-Formular, REST-Aufruf, Datenimport). Dieses Kapitel schließt genau diese Lücke mit einem Backend Model, das loyalty_tier bei jedem Speichervorgang automatisch aus loyalty_points_balance neu berechnet - ganz ohne Observer und ohne Plugin.
Die dritte Option neben Observer und Plugin
Block 4 und Block 5 erklären ausführlich, wann ein Observer (Kapitel 29-31) und wann ein Plugin (Kapitel 38-39) die richtige Wahl ist - Kapitel 37 liefert dazu später sogar eine explizite Entscheidungshilfe. Ein Backend Model ist eine dritte, oft übersehene Option, die ausschließlich für EAV-Attribute existiert: Es hängt nicht an einem Event-Namen oder einer konkreten Zielklasse, sondern direkt am Attribut selbst - aktiv überall dort, wo die Entität gespeichert wird, egal ob über das Admin-Formular, die REST-API oder einen programmatischen $customerRepository->save()-Aufruf.
Die Methoden von BackendInterface
\Magento\Eav\Model\Entity\Attribute\Backend\AbstractBackend implementiert BackendInterface und bietet fünf Hook-Punkte, in die eine eigene Unterklasse punktuell eingreifen kann:
beforeSave($object)- läuft unmittelbar vor dem eigentlichen INSERT/UPDATE, der klassische Ort für automatische Berechnung wie in diesem Kapitel.afterSave($object)- läuft danach, etwa um eine Nebentabelle synchron zu halten.afterLoad($object)- läuft beim Laden der Entität, etwa um einen abgeleiteten Anzeigewert vorzubereiten.validate($object)- eigene Validierungsregeln überrequired/uniquehinaus; eine fehlschlagende Validierung wirft eineLocalizedException.beforeDelete($object)/afterDelete($object)- selten gebraucht, etwa zum Aufräumen abhängiger Daten.
LoyaltyTierBackend implementieren
Die neue Klasse bekommt PointsCalculator (Kapitel 5) und LoyaltyConfig (Kapitel 7) per Konstruktor injiziert - ein Backend Model wird wie jedes andere Objekt über den ObjectManager erzeugt und unterstützt damit ganz normale Dependency Injection, nicht nur die von AbstractSource bekannten optionslosen Konstruktoren.
<?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) und die Projekt-Konvention aus CLAUDE.md verlangen eigentlich vollständige Typisierung - trotzdem bleibt der Parameter $object hier absichtlich ohne Klassen-Hinweis. Grund: AbstractBackend::beforeSave($object) deklariert in Magento-Core selbst keinen Parametertyp. PHP erlaubt es einer überschreibenden Methode nicht, einen zuvor ungetypten Parameter nachträglich einzuschränken (Kontravarianz-Regel) - der Versuch, hier \Magento\Framework\DataObject $object zu schreiben, endet mit einem Fatal Error ("Declaration ... must be compatible with ..."), sobald PHP die Klassenhierarchie prüft. Der Rückgabetyp dürfte dagegen ergänzt werden (Kovarianz ist erlaubt), bleibt hier aber bewusst ebenfalls weg, weil auch parent::beforeSave($object) ohne deklarierten Typ auskommt. Die volle Typinformation steckt stattdessen im PHPDoc-Block.
Backend Model per updateAttribute() anhängen
Genau wie der Scope-Wechsel in Kapitel 25 wird das Backend Model nachträglich per Data Patch an das bereits existierende Attribut loyalty_tier angehängt - die zugrunde liegende Datenbankspalte in eav_attribute heißt backend_model, unabhängig davon, ob sie über addAttribute() beim Anlegen oder über updateAttribute() nachträglich gesetzt wird.
<?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 bleibt im Admin-Formular weiterhin als editierbares Dropdown sichtbar (visible => true, Kapitel 21) - jede manuelle Auswahl wird jedoch beim nächsten Speichern stillschweigend überschrieben, sobald loyalty_points_balance im selben Aufruf mitgeladen ist. Wer dieses Verhalten sichtbar machen will, setzt das Feld zusätzlich per weiterem updateAttribute()-Patch auf visible => false und read_only => true - eine Übung, die exakt demselben Muster wie der Scope-Patch aus Kapitel 25 folgt und deshalb hier nicht wiederholt wird.
Tipp: $this->getAttribute()->getAttributeCode() statt der hart kodierten Zeichenkette 'loyalty_tier' zu verwenden mag hier wie unnötige Vorsicht wirken, weil die Klasse ohnehin nur an einem einzigen Attribut hängt - es ist trotzdem der Magento-Standard, weil er dieselbe Backend-Model-Klasse unverändert an einem umbenannten oder zweiten, ähnlichen Attribut wiederverwendbar macht, ganz ohne Codeänderung.
Ein einzelnes Attribut, das sich selbst konsistent hält, ist damit gelöst. Kapitel 27 geht einen Schritt weiter: Was passiert, wenn zwei unterschiedliche Attribute auf zwei unterschiedlichen Entitäten - Kategorie-Bonus und Produkt-Multiplikator - gemeinsam in eine einzige Berechnung einfließen und sich dabei widersprechen können?