Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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

app/code/Mironsoft/Loyalty/Model/Product/Type/PointsPackage.php
<?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

app/code/Mironsoft/Loyalty/Model/Product/Type/PointsPackage/Price.php
<?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.