Extensibility: How Other Developers Can Extend This Module
Extensibility: How Other Developers Can Extend This Module
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
A module that's only extensible with enormous effort has missed its purpose as a reusable foundation - no matter how clean its own code looks. This chapter flips the perspective: no longer "how do you build this module", but "how does another team build its own extension on top of it, without touching Mironsoft_Loyalty itself".
Service contracts as the extension surface
Because practically every core action runs through an interface - PointsLedgerRepositoryInterface (chapter 6), RewardRepositoryInterface (chapter 79), RewardRedemptionManagementInterface (chapter 81) - a foreign module can hook into any of these points with a plugin, exactly the way the three learning plugins from chapter 38 demonstrate on getListByCustomerId(). None of these core classes is marked final (unlike the fictional ErpSync\CorporateRewards\Model\MultiplierResolver from chapter 41, which deliberately served as a counterexample) - third-party code is free to attach plugins.
declare(strict_types=1);
namespace Vendor\LoyaltyReviews\Plugin;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
/**
* Extends the loyalty ledger read path with a review-bonus hint - a purely
* illustrative third-party plugin, no code change inside Mironsoft_Loyalty itself.
*/
final class HighlightReviewBonusPlugin
{
/**
* Adds a human-readable marker to review-bonus ledger entries after they are
* loaded, without touching the underlying repository implementation.
*
* @param PointsLedgerRepositoryInterface $subject The intercepted repository.
* @param PointsLedgerInterface[] $result The original ledger entries.
* @param int $customerId The customer whose ledger was requested.
* @return PointsLedgerInterface[] The (unmodified) list, side effects only.
*/
public function afterGetListByCustomerId(
PointsLedgerRepositoryInterface $subject,
array $result,
int $customerId
): array {
return $result;
}
}The module's own custom event as an extension point
LoyaltyTierBackend::EVENT_TIER_CHANGED (chapter 35) is this module's only self-dispatched event - and exactly for that reason the canonical extension point for anything that needs to react to a tier change without modifying the backend class itself. A fictional marketing module could, for instance, trigger a congratulations email off of it:
declare(strict_types=1);
namespace Vendor\LoyaltyMarketing\Observer;
use Magento\Framework\Event\Observer as EventObserver;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;
/**
* Reacts to the loyalty module's own tier-change event to trigger a marketing
* action - registered externally in etc/events.xml on
* "mironsoft_loyalty_customer_tier_changed" (chapter 35), no change to
* Mironsoft_Loyalty required.
*/
final class SendTierUpgradeCongratulationsObserver implements ObserverInterface
{
/**
* Initializes the observer with a logger dependency.
*
* @param LoggerInterface $logger Logs the congratulations trigger for auditing.
*/
public function __construct(
private readonly LoggerInterface $logger,
) {
}
/**
* Reads the event data payload (customer, previous_tier, new_tier, chapter 35)
* and triggers the actual marketing action.
*
* @param EventObserver $observer The dispatched event wrapper.
* @return void
*/
public function execute(EventObserver $observer): void
{
$newTier = (string) $observer->getEvent()->getData('new_tier');
$this->logger->info("Customer upgraded to tier: {$newTier}");
}
}<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="mironsoft_loyalty_customer_tier_changed">
<observer name="vendor_loyalty_marketing_send_tier_upgrade_congratulations"
instance="Vendor\LoyaltyMarketing\Observer\SendTierUpgradeCongratulationsObserver"/>
</event>
</config>Proven registration patterns worth copying
Three registration patterns from this series work unchanged as a template for your own, independent "sub-extension" of the same program - without changing any core class:
product_types.xml(chapter 72): a pattern for yet another, entirely separate product type - say, a third-party "points voucher" product type, structured analogously toPointsPackage.widget.xml(chapter 56): a pattern for another CMS widget that, just likePointsBalanceWidget, reuses an existing view model instead of duplicating business logic.- Payment method registration via
payment/*/model(chapter 62): a pattern for yet another custom payment method that, like the points payment method itself, lines up in the same checkout without altering the core checkout.
Understanding the preference exception, not copying it
CompanyMultiplierPreference (chapter 41) deliberately shows the special case where an extension has no plugin option - because the fictional target ErpSync\CorporateRewards\Model\MultiplierResolver is itself final. Not a single target inside Mironsoft_Loyalty itself is marked that way (except the stateless utility class PointsFormatter, chapter 44, which for exactly that reason offers no instance methods to override) - third-party code should therefore get by with a plugin for practically any extension of this module, not a preference.
Tipp: The new configuration type from chapter 88 is an extension point too: any foreign module can register its own values through the same app/etc/loyalty_flags.php pattern, as long as it respects the same ConfigTypeInterface contract - no database access at all, purely deploy-driven.
All this extensibility doesn't help much if, in a real incident, nobody knows where in the system something broke - chapter 103 delivers exactly that: the troubleshooting guide for the whole project.