Implementing the Product Type Model: Price Model and Type Model
Implementing the Product Type Model: Price Model and Type Model
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Chapter 72 registered the type code but pointed at two classes that didn't exist yet. This chapter supplies them - starting with the question of which base class the type model should even inherit from.
Why Virtual as the parent class?
Extending \Magento\Catalog\Model\Product\Type\AbstractType directly would be the "purest" solution, but it means rewriting every detail - shipping relevance, stock behavior, cart preparation - from scratch. A points package is, functionally, exactly what Virtual already models: not shippable, no physical stock in the traditional sense, plain quantity-times-price logic. PointsPackage extends Virtual inherits that entire, already tested base and overrides only the two spots where points packages actually differ - the same composition-over-reinvention stance chapters 62/66 already showed for payment and shipping methods, just via inheritance instead of di.xml composition this time, since AbstractType knows no adapter mechanism like Payment\Model\Method\Adapter.
The 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;
}
}
Purchase validation in _prepareProduct()
_prepareProduct() is the central entry point for every "Add to Cart" action - AbstractType calls it internally via the public prepareForCartAdvanced() method. The override here checks only whether loyalty_points_package_amount (chapter 74) actually carries a positive value before delegating to parent::_prepareProduct() - everything else (stock checks, quantity validation) is already fully handled by Virtual.
Achtung: $product and $processMode deliberately stay untyped: AbstractType::_prepareProduct() declares no parameter type for either one, and PHP forbids narrowing (a contravariance break) on override - exactly the same restriction LoyaltyTierBackend::beforeSave() already documented for $object in chapter 26.
getOrderOptions() for cart and order
additional_options isn't a Mironsoft invention - it's an array of label/value pairs that Magento's own cart and order item templates already read generically, the same pattern core bundle and gift card products use. The payoff only becomes visible in chapter 75: not a single extra cart or order template is needed to surface the points amount in the cart and order summary.
The 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() effectively changes nothing here - the inherited Price class already returns true for simple/virtual anyway. The point of overriding it is purely documentary: a deliberate, code-visible decision instead of a silent accident of inheritance, laying the groundwork for chapter 77. Equally important: the newer, generic pricing rendering layer (Magento\Framework\Pricing) that produces PDP/PLP price HTML works type-agnostically off the price/special_price attributes for standard prices - no further di.xml registration is needed for that.
With the type and price models in place, a points package product can now be created without technical errors - chapter 74 turns that into a usable admin form next.