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
<?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;
}<?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.
<?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);
}
}<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.