Checklist instead of theory, for shipping, quote totals and order email
Anyone who re-debates whether a preference or a plugin is the right choice for every new requirement wastes time in code review and risks conflicts with third-party modules. This decision rule delivers a fixed checklist with scoring, applied to four real Magento 2.4.8 scenarios that recur regularly in agency practice.
Table of contents
- 1. Why a decision rule is needed, not more theory
- 2. Criterion 1: final classes and backward compatibility limits
- 3. Criterion 2: changing the method signature or wrapping behavior
- 4. Criterion 3: compatibility with already existing plugins
- 5. Criterion 4: resource models and collections as a risk zone
- 6. Scenario A: custom shipping cost calculation
- 7. Scenario B: overriding the quote address total collector
- 8. Scenario C and D: order email sender and product price calculation
- 9. Detecting sortOrder conflicts with dev:di:info
- 10. Summary
- 11. FAQ
1. Why a decision rule is needed, not more theory
The fundamentals are explained in detail in our earlier articles on preferences and plugins: what an interceptor technically does, how the proxy object is generated, which method prefixes before, around and after distinguish. That theory is deliberately not repeated here. The actual problem in agency practice lies elsewhere: A developer faces a concrete requirement, for example an adjusted shipping cost calculation, and has to decide within a few minutes whether to write a preference or a plugin, without that decision being reopened in code review.
This is exactly where the decision rule in this article comes in. Instead of explaining again what a plugin is, this text delivers a flowchart with four hard criteria that can be answered in seconds: Is the target class final, or not extensible per backward compatibility policy? Does the method signature need to change, or is it enough to wrap behavior around it? Does a competing plugin from another vendor already exist on the same method? Is the target a resource model or a collection, where plugins are particularly error-prone? Once these four questions are answered, the decision preference or plugin has already been made in practice, without a single line of code written.
2. Criterion 1: final classes and backward compatibility limits
The first and hardest criterion of any decision rule is technically binding: If the target class is declared as final, no preference can replace it via a preference node in di.xml and inherit at the same time, because PHP strictly forbids inheriting from final classes. In this case only a plugin remains, provided the method itself is not also final, or a complete reimplementation with its own interface, which in most cases is disproportionately expensive. Magento increasingly marks classes as final, especially in newer modules and in the checkout area, in line with the official backward compatibility policy that distinguishes between "internal" and "api" code.
Less obvious, but just as important: Even for non-final classes, Magento marks via the @api annotation and the directory structure which classes are meant as stable extension points. A preference on an internal model class without an @api marker works technically, but breaks on every minor update without warning, because Magento refactors these classes without regard for backward compatibility. The practical consequence for the checklist: before every preference decision, check the source code for final class and for the presence of an @api annotation in the class docblock or in the module's corresponding api.xml declaration.
3. Criterion 2: changing the method signature or wrapping behavior
The second criterion of the decision rule concerns the nature of the desired change. If the return type of a method needs to change, if a new required parameter needs to be introduced, or if the internal construction logic of an object needs to be completely swapped out, for example because a different collection type or a different repository should be injected, then a preference is almost always the right choice. A plugin cannot introduce a new signature that other code in the system does not expect, because all callers continue to program against the original interface. If, on the other hand, only additional behavior needs to be placed before, after, or around an existing call, for example an additional validation before execution, a log line after the result, or a conditional adjustment of the return value, a plugin is almost always preferable, because it stays additive and can coexist with other modules.
A practical example of this boundary: If the entire calculation logic of a pricing class needs to be swapped out because a completely new pricing model with its own data sources is required, that is a preference, because at its core a new implementation is created. If, on the other hand, only a discount surcharge should be applied to the already calculated result, an after plugin is the correct decision rule, because the original calculation is preserved and only the result is modified. Anyone who fails to draw this distinction cleanly often ends up with preferences that copy the entire original code and change only one line, which leads to silent merge conflicts on every Magento update.
4. Criterion 3: compatibility with already existing plugins
The third criterion is underestimated in many projects: If a plugin from a third-party module, from another vendor in a dual-vendor setup, or from a marketplace extension already exists on the same method, the decision rule shifts almost automatically toward a plugin, because two plugins on the same method can coexist as long as the sortOrder values are properly maintained. A preference, in contrast, is exclusive: only a single preference can be active per type. If a second module also installs a preference on the same class, the last one loaded wins, and the other is silently ignored, without Magento throwing an error.
In agency practice this means: before every preference decision on a core class that is potentially also touched by third-party extensions, for example shipping modules, payment modules, or tax calculation, check with bin/magento dev:di:info whether a preference already exists. If an entry from a third-party module is already found there, an own preference is in most cases no longer an option, unless one deliberately replaces the third-party preference by inheriting from its class, which entails an explicit dependency in module.xml. A plugin, by contrast, simply joins the existing chain with a matching sortOrder.
5. Criterion 4: resource models and collections as a risk zone
The fourth criterion concerns a category of target classes where plugins are particularly error-prone: resource models and collections. Methods such as Magento\Framework\Model\ResourceModel\Db\AbstractDb::save() or collection methods like addFieldToFilter() and load() are called internally extremely often, sometimes recursively, sometimes in loops over thousands of records. An around plugin on such a method adds extra overhead through proxy generation on every call, and if implemented incorrectly, for example by failing to call proceed(), can silently prevent the entire save operation.
The practical consequence for the decision rule: for resource models and collections, prefer events (catalog_product_save_before, sales_order_save_after and similar) over plugins where possible, because events do not create a proxy class and cause no performance overhead on every single method call. If a plugin is still necessary, for example because no suitable event exists, then only as before or after, never as around, unless the requirement strictly demands intercepting the return value or conditionally preventing the original call. A preference on a resource model is usually the worst choice of all four options, because it is exclusive and highly likely to collide with enterprise modules such as Multi-Source Inventory, which themselves reach deep into these classes.
6. Scenario A: custom shipping cost calculation
An agency client needs a shipping cost calculation that, in addition to the standard table rate, calculates a surcharge for bulky-goods items, based on a custom attribute on the product. According to the checklist: The target class Magento\OfflineShipping\Model\Carrier\Tablerate is not final, no new method signature is needed, it is unlikely that another module plugs the same carrier class, and it is not a resource model. The decisive question remains criterion 2: Is only a surcharge added to the already calculated result, or is the entire calculation logic replaced?
Since here only an additional amount should be added to the result of the collectRates() method, while the original table rate logic remains fully intact, the decision rule clearly falls on an after plugin. A plugin stays additive, can coexist with other shipping modules that may also extend the Tablerate class, and creates no merge risk on a Magento update, because no original code was copied. If, instead, a completely new tariff model with an external API connection were needed that replaces the entire method and returns a different internal data structure, a preference, or better a standalone new carrier class, would be the right choice, not a preference on the existing Tablerate class.
| Criterion | Speaks for preference | Speaks for plugin |
|---|---|---|
| Class final or without @api | Not possible if final, risky without @api | Usually the only option for final classes |
| Change method signature | Required for new parameter/return type | Technically not possible |
| Third-party module plugin already exists | Exclusive, completely overrides third-party module | Coexists via sortOrder |
| Resource model / collection | High collision risk with MSI/enterprise | Only before/after, avoid around |
| Only enrich result (additive) | Unnecessary effort, copies original code | Ideal, minimal intervention |
| Fully replace object construction | Only clean solution | Not designed for constructor logic |
| Update safety on core refactoring | Breaks on changed internal structure | Breaks only on changed method signature |
<!-- app/code/Mironsoft/Shipping/etc/di.xml -->
<!-- Scenario A: Plugin approach for surcharge on top of Tablerate result -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\OfflineShipping\Model\Carrier\Tablerate">
<plugin name="mironsoft_bulky_item_surcharge"
type="Mironsoft\Shipping\Plugin\BulkyItemSurchargePlugin"
sortOrder="20"/>
</type>
</config>
<?php
// app/code/Mironsoft/Shipping/Plugin/BulkyItemSurchargePlugin.php
// Scenario A: after-plugin, adds a surcharge to the calculated shipping result
declare(strict_types=1);
namespace Mironsoft\Shipping\Plugin;
use Magento\OfflineShipping\Model\Carrier\Tablerate;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Shipping\Model\Rate\Result;
/**
* Adds a bulky item surcharge to the tablerate shipping result.
* Uses after-plugin because only the already computed result is enriched,
* the original tablerate calculation stays untouched.
*/
final class BulkyItemSurchargePlugin
{
/**
* Surcharge amount per bulky item in the quote, in store currency.
*/
private const BULKY_SURCHARGE = 12.50;
/**
* @param Tablerate $subject Original carrier instance.
* @param Result|bool $result Result of collectRates(), or false if no rate found.
* @param RateRequest $request Original rate request with quote items.
* @return Result|bool Modified result with surcharge applied, or unchanged bool.
*/
public function afterCollectRates(
Tablerate $subject,
Result|bool $result,
RateRequest $request
): Result|bool {
if ($result === false) {
return $result;
}
$hasBulkyItem = false;
foreach ($request->getAllItems() ?? [] as $item) {
if ((bool) $item->getProduct()->getData('is_bulky_item')) {
$hasBulkyItem = true;
break;
}
}
if ($hasBulkyItem) {
foreach ($result->getAllRates() as $rate) {
$rate->setPrice($rate->getPrice() + self::BULKY_SURCHARGE);
}
}
return $result;
}
}
For comparison, the counter-case in the same scenario: If the entire tariff calculation had to be replaced by an external freight API, including its own internal data structure for zones and weight classes, the decision rule would be different. No additive plugin is sufficient here anymore, because the entire calculation logic of collectRates() would need to be swapped out, not just the result enriched. In this case a preference on the carrier class would be the right choice, as the following counter-example shows.
<!-- app/code/Mironsoft/Shipping/etc/di.xml -->
<!-- Counter-example for the same scenario: preference approach if the entire -->
<!-- rate calculation had to be replaced by an external freight API -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="Magento\OfflineShipping\Model\Carrier\Tablerate"
type="Mironsoft\Shipping\Model\Carrier\ExternalFreightRate"/>
</config>
<?php
// app/code/Mironsoft/Shipping/Model/Carrier/ExternalFreightRate.php
// Counter-example: preference class, full replacement of the rate calculation
declare(strict_types=1);
namespace Mironsoft\Shipping\Model\Carrier;
use Magento\OfflineShipping\Model\Carrier\Tablerate;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Shipping\Model\Rate\Result;
use Magento\Shipping\Model\Rate\ResultFactory;
use Mironsoft\Shipping\Api\ExternalFreightClientInterface;
/**
* Replaces the tablerate calculation entirely with rates from an external
* freight API. Implemented as a preference because the whole calculation
* and its internal zone and weight class data structure are replaced,
* not just enriched with an additional plugin step.
*/
final class ExternalFreightRate extends Tablerate
{
/**
* @param ExternalFreightClientInterface $freightClient External freight API client.
* @param ResultFactory $rateResultFactory Factory for building the shipping rate result.
*/
public function __construct(
private readonly ExternalFreightClientInterface $freightClient,
private readonly ResultFactory $rateResultFactory,
mixed ...$parentArgs
) {
parent::__construct(...$parentArgs);
}
/**
* Collects shipping rates entirely from the external freight API,
* replacing the standard tablerate zone and weight lookup.
*
* @param RateRequest $request Rate request with quote items and destination.
* @return Result|bool
*/
public function collectRates(RateRequest $request): Result|bool
{
if (!$this->getConfigFlag('active')) {
return false;
}
$quote = $this->freightClient->getQuote($request);
if ($quote === null) {
return false;
}
$result = $this->rateResultFactory->create();
$result->append($quote->toShippingRate());
return $result;
}
}
7. Scenario B: overriding the quote address total collector
A second real case: The client needs a completely new total collector that outputs a company-specific loyalty bonus as its own line in the cart totals summary, with its own calculation order relative to discount and tax. Total collectors implement Magento\Quote\Model\Quote\Address\Total\AbstractTotal and are not addressed via di.xml type configuration of a single interface, but via the sales sequence configuration in sales.xml, where each collector gets its own code and sort position.
According to the checklist, the picture here is clear: a completely new calculation logic is needed, no existing collector is merely being enriched, so this is not a candidate situation for a plugin on an existing method. Instead, a new collector class is created that implements AbstractTotal, and is additionally registered via sales.xml. Should the behavior of an existing collector, such as the discount collector, also need to be adjusted, for example to exclude the loyalty bonus from the discount, a separate plugin on the collect() method of the existing collector is the right decision rule for that, not a preference on the entire collector. This combination of a new collector class plus a targeted plugin on an existing collector is in practice the most common case for adjustments to the total calculation.
In practical implementation, the new collector class gets a unique code in sales.xml as well as a sortOrder position relative to discount, tax and shipping cost. The constructor of the collector class consistently follows PHP 8.4 constructor property promotion for injected services such as a calculation service for the loyalty bonus. The collect() method first calls parent::collect() and then adds its own amount via addTotalAmount(), while fetch() supplies the display line for the cart totals summary. This structure remains the same regardless of whether an additional targeted plugin is registered on an existing collector or not.
8. Scenario C and D: order email sender and product price calculation
Scenario C: The client wants order confirmations for B2B customers with a high order value to receive an additional internal copy sent to the sales team, without changing the actual dispatch logic of the email to the customer. The target class is Magento\Sales\Model\Order\Email\Sender\OrderSender. According to the checklist: The class is not final, no signature change is needed, it is plausible that other modules already have plugins registered on the same sender (for example newsletter or CRM integrations), and it is not a resource model. The decision rule clearly falls on an after plugin on the send() method that additionally triggers a BCC copy, without affecting the dispatch of the actual customer email. A preference would be grossly oversized here and would lead to conflicts with every Magento security patch to the order email module.
Scenario D: The client needs a completely different price calculation logic for a product group with dynamic, daily updated commodity prices coming from an external API, which should fully replace the standard price calculation of Magento\Catalog\Model\Product\Type\Price, including a changed internal caching structure for the calculated prices. Two criteria apply here at once: a completely new internal calculation logic with its own caching is needed, not an additive addition, and the price calculation is called from many places in checkout, product listing, and cart, where a plugin with an incorrect around implementation could lead to inconsistent price displays. The decision rule falls here on a preference, because the entire internal construction logic of the price determination is swapped out and no existing behavior is merely being wrapped.
For scenario D, the decision means in practice: the preference class inherits from Magento\Catalog\Model\Product\Type\Price, injects an external price service and an internal price cache via constructor property promotion, and overrides only getFinalPrice() for products of the affected group, while falling back transparently to parent::getFinalPrice() for all other products. Exactly this structure, a new calculation path plus a clean fallback to the standard logic, distinguishes a sensibly scoped preference from a risky preference that unnecessarily copies the entire original code.
9. Detecting sortOrder conflicts with dev:di:info
As soon as multiple vendors, for example the Mironsoft module and an installed marketplace module, register plugins on the same method, only the sortOrder value decides the execution order, not the alphabetical order of module names and not the installation timestamp. Two plugins with an identical sortOrder are sorted deterministically but implicitly by internal module order, which can lead to a silent behavior change when the module order is updated. That is why checking for sortOrder conflicts belongs in every code review checklist, before a new plugin is registered on a method already plugged by third parties.
The CLI command bin/magento dev:di:info "ClassName" lists all registered plugins for a given class, including their sortOrder values and their execution order. For total collectors and questions about preference resolution, dev:di:info additionally supplies the currently active preference target class, if one is configured. This output is the fastest way to check, before writing a new plugin, whether third-party code is already active on the same method, and to find out, in case of a bug after an update, which module intervenes in a specific method with which priority.
# Detect plugin sortOrder conflicts before registering a new plugin
bin/magento dev:di:info "Magento\Sales\Model\Order\Email\Sender\OrderSender"
# Expected output includes a plugin list similar to:
# Plugins for class Magento\Sales\Model\Order\Email\Sender\OrderSender
# Type: Magento\Sales\Model\Order\Email\Sender\OrderSender
# [around] someVendor_crm_sync (sortOrder=10)
# [after] mironsoft_bcc_sales_team (sortOrder=20)
# Also check which preference (if any) is currently active for a type
bin/magento dev:di:info "Magento\Catalog\Model\Product\Type\Price"
# Clear generated interception cache after adding or changing plugin sortOrder
bin/magento cache:clean config di
10. Summary
The decision preference or plugin can in practice be reduced to four fixed criteria that should be answered before a single line of code: Is the target class final or without an @api marker, is a change to the method signature needed, does a third-party module plugin already exist on the same method, and is it a resource model or a collection. The four new practical scenarios in this article, from shipping cost calculation through the quote total collector to the order email sender and product price calculation, show that this decision rule leads in every case to a clear, justifiable answer, instead of a gut decision in code review.
Anyone who consistently applies this checklist not only reduces the number of preferences in the project, which directly increases update safety on Magento patches, but also prevents silent collisions with third-party modules that arise from sortOrder conflicts or overwritten preferences. The dev:di:info command belongs firmly in the development workflow, not only for retroactive troubleshooting, but as a standard step before every new registration in di.xml.
Preference or plugin, the decision rule at a glance
Check final and @api
For final classes or a missing @api marker, a preference is often technically impossible or update-unsafe. The first question of every decision rule.
Signature vs. additive behavior
New signature or complete reimplementation: preference. Additional behavior around an existing call: plugin.
Third-party module compatibility
Existing plugin from another vendor on the same method: check with dev:di:info, then join with your own plugin via sortOrder.
Securing resource models
For resource models and collections prefer events, use plugins only as before/after, avoid around, avoid preferences.
11. FAQ: preference or plugin decision rule
1How do I quickly decide between preference or plugin?
2Is a preference on a final class possible?
3Two preferences on the same class?
4Why are plugins on resource models risky?
5How do I find existing plugins on a method?
6How does sortOrder determine the order?
7New quote total collector: preference or own class?
8Preference justified for order email?
9Effect on update safety?
10Does the checklist need to run every time?
Mironsoft
Magento 2 architecture, code review and dependency injection consulting
Ready for clean preference and plugin decisions in your team?
We help establish a fixed decision rule, audit existing di.xml configurations, and resolve sortOrder conflicts with third-party modules.
DI audit
Systematically review existing preferences and plugins with dev:di:info
Team standards
Establish the decision checklist as a fixed part of code review
Refactoring
Replace risky preferences with safe plugins or events