Produkttyp-Modell implementieren: Price-Model und Type-Model
Produkttyp-Modell implementieren: Price-Model und Type-Model
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Kapitel 72 hat den Typ-Code registriert, aber auf zwei noch nicht existierende Klassen verwiesen. Dieses Kapitel liefert sie - und beginnt mit der Frage, von welcher Basisklasse das Type-Model überhaupt erben sollte.
Warum Virtual als Elternklasse?
\Magento\Catalog\Model\Product\Type\AbstractType direkt zu erweitern wäre die "reinste" Lösung, bedeutet aber, jedes Detail - Versandrelevanz, Lagerverhalten, Warenkorb-Vorbereitung - komplett neu zu schreiben. Ein Punkte-Paket ist fachlich exakt das, was Virtual bereits abbildet: nicht versandfähig, kein Lagerbestand im physischen Sinn, einfache Menge-mal-Preis-Logik. PointsPackage extends Virtual erbt diese komplette, bereits getestete Basis und überschreibt nur die zwei Stellen, an denen sich Punkte-Pakete tatsächlich unterscheiden - dieselbe Komposition-vor-Neuerfindung-Haltung, die Kapitel 62/66 bei Zahlungs- und Versandarten schon gezeigt haben, nur diesmal über Vererbung statt di.xml-Komposition, weil AbstractType keinen Adapter-Mechanismus wie Payment\Model\Method\Adapter kennt.
Das Type-Model
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Product\Type;
use Magento\Catalog\Model\Product\Type\Virtual;
use Magento\Framework\DataObject;
/**
* Product type model for a purchasable "points package" - a non-shippable
* product whose sole purpose is to credit a fixed number of loyalty points to
* the buyer once the order is placed (see
* Observer\CreditPurchasedPointsPackageOnOrderPlaced, chapter 76). Extends
* Virtual to reuse its non-shippable behavior wholesale instead of
* reimplementing it - it is the distinct type code below, not this
* inheritance, that makes this a real, separately addressable product type
* (see chapter 71 for why that distinction matters).
*/
class PointsPackage extends Virtual
{
/**
* Product type code, registered in etc/product_types.xml (chapter 72) and
* referenced from CreditPurchasedPointsPackageOnOrderPlaced (chapter 76)
* instead of a fragile boolean product attribute.
*/
public const string TYPE_CODE = 'loyalty_points_package';
/**
* Rejects add-to-cart requests for a points package that has no positive
* loyalty_points_package_amount configured (chapter 74) - a missing or
* zero amount would silently credit nothing once the order is placed.
* $product and $processMode stay untyped: the parent
* AbstractType::_prepareProduct() declares no type hints for them
* either, and PHP forbids narrowing (contravariance) on override.
*
* @param DataObject $buyRequest Add-to-cart request data
* @param mixed $product Product being added to the cart
* @param mixed $processMode One of AbstractType::PROCESS_MODE_* constants
* @return array|string Array of prepared products, or an error string
*/
protected function _prepareProduct(DataObject $buyRequest, $product, $processMode)
{
$pointsAmount = (int) $product->getData('loyalty_points_package_amount');
if ($pointsAmount <= 0) {
return __('This points package has no points amount configured.')->render();
}
return parent::_prepareProduct($buyRequest, $product, $processMode);
}
/**
* Adds the package's point content to the generic "additional options"
* array that Magento's own cart and order item templates already render
* for every product type - no extra frontend template is needed for
* this (see chapter 75). $product stays untyped for the same LSP reason
* as _prepareProduct() above: AbstractType::getOrderOptions() declares
* no type hint for it either.
*
* @param mixed $product Product the order item was created from
* @return array<string, mixed>
*/
public function getOrderOptions($product)
{
$optionArr = parent::getOrderOptions($product);
$pointsAmount = (int) $product->getData('loyalty_points_package_amount');
if ($pointsAmount > 0) {
$optionArr['additional_options'][] = [
'label' => __('Bonus points'),
'value' => (string) $pointsAmount,
];
}
return $optionArr;
}
}
Kaufvalidierung in _prepareProduct()
_prepareProduct() ist der zentrale Einstiegspunkt jedes "In den Warenkorb"-Vorgangs - AbstractType ruft ihn intern über die öffentliche prepareForCartAdvanced()-Methode auf. Die Überschreibung hier prüft ausschließlich, ob loyalty_points_package_amount (Kapitel 74) überhaupt einen positiven Wert trägt, bevor sie an parent::_prepareProduct() delegiert - alles andere (Lagerprüfung, Mengenvalidierung) übernimmt Virtual bereits vollständig.
Achtung: $product und $processMode bleiben bewusst ohne Typ-Hinweis: AbstractType::_prepareProduct() deklariert für beide keinen Parametertyp, und PHP verbietet eine nachträgliche Verschärfung (Kontravarianz-Bruch) beim Überschreiben - exakt dieselbe Einschränkung, die LoyaltyTierBackend::beforeSave() in Kapitel 26 bereits für $object dokumentiert hat.
getOrderOptions() für Warenkorb und Bestellung
additional_options ist keine Mironsoft-Erfindung, sondern ein von Magentos eigenen Cart- und Order-Item-Templates bereits generisch ausgelesenes Array aus label/value-Paaren - dasselbe Muster, das Bundle- und Gift-Card-Produkte im Kern verwenden. Der Nutzen zeigt sich erst in Kapitel 75: kein einziges zusätzliches Cart- oder Order-Template ist nötig, um die Punktezahl im Warenkorb und in der Bestellübersicht sichtbar zu machen.
Das Price-Model
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Product\Type\PointsPackage;
/**
* Price model for the loyalty_points_package product type. A thin, currently
* behavior-preserving subclass of the generic Price model - its purpose is
* not to change pricing today, but to give product_types.xml (chapter 72) a
* dedicated priceModel class to point at, so a future change (e.g. bundled
* package pricing tiers) never has to touch etc/product_types.xml again.
*/
class Price extends \Magento\Catalog\Model\Product\Type\Price
{
/**
* Explicitly documents that points packages use fixed tier prices, like
* simple and virtual products - not the percentage-based tier prices
* Configurable products use. Returns the same value the inherited
* default already would; the override exists to make the decision
* visible in code rather than leaving it an accident of inheritance
* (see chapter 77 for why that matters once price rules enter the
* picture).
*
* @return bool
*/
public function isTierPriceFixed(): bool
{
return true;
}
}
Tipp: isTierPriceFixed() ändert hier faktisch nichts - die geerbte Price-Klasse liefert für simple/virtual ohnehin bereits true. Der Sinn der Überschreibung ist rein dokumentarisch: eine bewusste, im Code sichtbare Entscheidung statt eines stillen Vererbungs-Zufalls, direkt vorbereitet auf Kapitel 77. Ebenso wichtig: die neuere, generische Pricing-Rendering-Schicht (Magento\Framework\Pricing), die PDP/PLP-Preis-HTML erzeugt, arbeitet für Standardpreise typübergreifend direkt über die price/special_price-Attribute - dafür ist keine weitere di.xml-Registrierung nötig.
Mit Type- und Price-Model an Ort und Stelle lässt sich ein Punkte-Paket-Produkt jetzt technisch fehlerfrei anlegen - Kapitel 74 macht daraus als Nächstes ein brauchbares Admin-Formular.