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

Writing a Custom Preference for the Points Calculation

Writing a Custom Preference for the Points Calculation

~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026

The business scenario from chapter 40: this shop already runs a licensed third-party extension that synchronizes the B2B special conditions from chapter 22 with an external ERP system - fictional vendor namespace ErpSync\CorporateRewards. New requirement: the ERP-driven company multiplier must never fall below the value that loyalty_tier_override (chapter 22) specifies for the company - but the extension itself naturally has no idea that attribute exists.

The starting point: a third-party final method

vendor/erp-sync/corporate-rewards/Api/MultiplierResolverInterface.php
<?php

declare(strict_types=1);

namespace ErpSync\CorporateRewards\Api;

/**
 * Vendor-provided contract for resolving a company's ERP-driven pricing
 * multiplier. Part of a licensed third-party extension, not this project's code.
 */
interface MultiplierResolverInterface
{
    /**
     * @param int $companyId Company entity ID.
     * @return float
     */
    public function resolveForCompany(int $companyId): float;
}
vendor/erp-sync/corporate-rewards/Model/MultiplierResolver.php
<?php

declare(strict_types=1);

namespace ErpSync\CorporateRewards\Model;

use ErpSync\CorporateRewards\Api\MultiplierResolverInterface;

/**
 * Default implementation, shipped by the vendor. resolveForCompany() is
 * declared final on purpose - the vendor wants every shop's ERP multiplier
 * to always come straight from their sync service, with no local drift.
 */
class MultiplierResolver implements MultiplierResolverInterface
{
    // ... constructor with the vendor's own ERP client dependency ...

    /**
     * @param int $companyId Company entity ID.
     * @return float
     */
    final public function resolveForCompany(int $companyId): float
    {
        // ... calls the external ERP system, falls back to 1.0 on error ...
    }
}

The vendor already registers their own implementation via a preference in their own di.xml: <preference for="ErpSync\CorporateRewards\Api\MultiplierResolverInterface" type="ErpSync\CorporateRewards\Model\MultiplierResolver"/>.

Why a plugin fails here

A plugin on resolveForCompany() would abort with a PHP fatal error during setup:di:compile (or on the first runtime call, if the compiler step gets skipped): the generated interceptor class would have to extend MultiplierResolver and override resolveForCompany() - exactly what final categorically forbids. There's no way to adjust this one detail via a plugin without removing the final declaration itself (and thereby editing the source of a licensed third-party module - not an option).

The fix: a preference using composition, not inheritance

The key trick: the custom preference class does not extend MultiplierResolver - instead it implements MultiplierResolverInterface directly and holds the original implementation as an ordinary, injected dependency (composition). Since final only forbids inheritance, not holding an instance as a collaborator, the vendor's original algorithm remains fully usable - it's just no longer overridable.

app/code/Mironsoft/Loyalty/Model/Erp/CompanyMultiplierPreference.php
<?php

declare(strict_types=1);

namespace Mironsoft\Loyalty\Model\Erp;

use ErpSync\CorporateRewards\Api\MultiplierResolverInterface;
use ErpSync\CorporateRewards\Model\MultiplierResolver;
use Magento\Company\Api\CompanyRepositoryInterface;
use Mironsoft\Loyalty\Model\Source\LoyaltyTier;

/**
 * Replaces the vendor's MultiplierResolver via a di.xml preference. Composes
 * - rather than extends - the vendor's final-method implementation, so its ERP
 * logic stays intact while a project-specific floor value is layered on top.
 */
class CompanyMultiplierPreference implements MultiplierResolverInterface
{
    /**
     * @var array<string, float>
     */
    private const TIER_FLOOR_MULTIPLIERS = [
        LoyaltyTier::TIER_BRONZE => 1.0,
        LoyaltyTier::TIER_SILVER => 1.2,
        LoyaltyTier::TIER_GOLD => 1.5,
    ];

    /**
     * @param MultiplierResolver $originalResolver The vendor's own final-method implementation, composed rather than extended.
     * @param CompanyRepositoryInterface $companyRepository Loads the company to read loyalty_tier_override (chapter 22).
     */
    public function __construct(
        private readonly MultiplierResolver $originalResolver,
        private readonly CompanyRepositoryInterface $companyRepository,
    ) {
    }

    /**
     * Resolves the ERP multiplier, raised to the project's tier floor if needed.
     *
     * @param int $companyId Company entity ID.
     * @return float
     */
    public function resolveForCompany(int $companyId): float
    {
        $erpMultiplier = $this->originalResolver->resolveForCompany($companyId);

        $company = $this->companyRepository->get($companyId);
        // @phpstan-ignore-next-line CompanyInterface::getData() is not on the interface but exists on the model
        $tierOverride = (string) $company->getData('loyalty_tier_override');
        $floor = self::TIER_FLOOR_MULTIPLIERS[$tierOverride] ?? 0.0;

        return max($erpMultiplier, $floor);
    }
}
app/code/Mironsoft/Loyalty/etc/di.xml
<preference for="ErpSync\CorporateRewards\Api\MultiplierResolverInterface"
            type="Mironsoft\Loyalty\Model\Erp\CompanyMultiplierPreference"/>

Where this value feeds in later

CompanyMultiplierPreference deliberately stays self-contained and isn't wired into PointsCalculator::calculatePoints() (chapter 5) yet - that would mean a new constructor dependency on a class this series has already fixed, and sits outside today's chapter focus. For this chapter, what counts is the preference technique itself: a final obstacle cleanly routed around, without touching someone else's source code.

Tipp: The composition-over-inheritance approach pays off in testing too: CompanyMultiplierPreference can be instantiated in isolation by swapping MultiplierResolver and CompanyRepositoryInterface for test doubles in a unit test (chapters 91/92) - no ObjectManager, no generated interceptor, no setup overhead.

Achtung: Only one preference ever wins per interface. If any other, later-loaded module also registers a preference for MultiplierResolverInterface, CompanyMultiplierPreference silently never gets instantiated - no error message at all. Chapter 42 shows what that risk looks like in practice and how to contain it.