Resolving conflicts between modules deliberately
As soon as two or more modules patch the same method, plugin sort order decides the application's actual behavior, often without a developer noticing. sortOrder conflicts rarely show up as a clear error but as subtly wrong behavior that can only be cleanly diagnosed with dev:di:info and a solid understanding of interceptor execution.
Table of Contents
- 1. Why plugin order becomes a real problem
- 2. Fundamentals: sortOrder in di.xml
- 3. Execution model: before, around, after in detail
- 4. Conflict scenario: two modules patch the same method
- 5. Diagnosis with dev:di:info
- 6. Fixing it without touching foreign code
- 7. Using disable="true" deliberately
- 8. Conventions to prevent future conflicts
- 9. Comparison: risky vs. robust approaches
- 10. Summary
- 11. FAQ
1. Why plugin order becomes a real problem
In a small project with just a handful of custom plugins, plugin sort order is rarely a topic. But as a project grows, several third-party extensions get installed, and different teams independently register plugins on the same core classes, the order in which these plugins execute becomes one of the subtlest and hardest to debug problems in the entire Magento architecture. Unlike a classic PHP error, an incorrect plugin order rarely produces an exception, but usually a quietly wrong result: a discount gets applied to the gross price instead of the net price, a validation runs after instead of before a data change, a cache gets invalidated before the actual write operation completes.
This article assumes the reader already knows what a plugin is in Magento 2 and how before, around, and after fundamentally work. The focus is exclusively on the sortOrder attribute in di.xml, what happens when two or more modules patch the same method with conflicting or undefined sortOrder values, and concrete techniques to diagnose and fix such plugin sort order conflicts, even when one of the involved modules is a third-party package you can't edit directly.
The good news up front: Magento's interceptor mechanism is deterministic. There is no "random" execution order, but a clearly defined rule based on sortOrder values and, in case of a tie, on module load order. Anyone who understands this rule can resolve every plugin sort order conflict systematically instead of through trial and error.
2. Fundamentals: sortOrder in di.xml
Every plugin is registered in di.xml with a <plugin> element that optionally carries a sortOrder attribute. If the attribute is missing, Magento implicitly assumes the value 0. If several modules register a plugin on the same method of the same class without each setting an explicit sortOrder, they all end up at the default value 0, and the actual execution order is then determined by the module load order, in other words ultimately by the sequence declarations in the respective module.xml files. This is the core of many plugin sort order problems: two independent module authors both implicitly assume they run "first," without anyone having explicitly set that via sortOrder.
Lower sortOrder values fundamentally run earlier than higher ones. This applies to before methods in ascending order, to after methods in exactly the reverse, descending order, and to around methods as nested calls where the plugin with the lowest sortOrder sits furthest outside. These rules are walked through in section 3 using a concrete example, because a purely numeric understanding of "lower before higher" isn't enough in practice to correctly predict more complex interceptor chains.
<?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\Catalog\Model\Product">
<!-- No sortOrder given: defaults to 0, order vs. other unset plugins
depends on module load order, not on this declaration -->
<plugin name="vendor_tax_final_price" type="Vendor\Tax\Plugin\ProductPlugin"/>
<!-- Explicit sortOrder: always runs after plugins with a lower value -->
<plugin name="vendor_discount_final_price" type="Vendor\Discount\Plugin\ProductPlugin" sortOrder="20"/>
</type>
</config>
3. Execution model: before, around, after in detail
To really understand plugin sort order conflicts, a concrete example with three plugins on the same method helps: plugin A with sortOrder="10", plugin B with sortOrder="20", plugin C with sortOrder="30", all three with before, around, and after methods. The before methods run in ascending sortOrder order: first A, then B, then C, each directly before the call to the original method or before the next around in the chain.
The around methods, on the other hand, nest like onion layers: plugin A with the lowest sortOrder sits furthest outside, so it calls $proceed() first, which in turn starts plugin B's around, which itself calls $proceed() and thereby starts plugin C, which finally calls the original method. The actual execution order of the around calls is therefore A, then B, then C, then the original method, then C again, then B, then A, symmetric like entering and leaving nested brackets. The after methods finally run in exactly reversed, descending sortOrder order: first C, then B, then A.
declare(strict_types=1);
namespace Vendor\Discount\Plugin;
use Magento\Catalog\Model\Product;
use Closure;
/**
* Demonstrates the before/around/after execution model with a single plugin.
*/
final class ProductPricePlugin
{
/**
* Runs before the original method, in ascending sortOrder order among all before-plugins.
*
* @param Product $subject The intercepted product instance
* @param float $basePrice Argument passed to the original method
* @return array{0: float} Adjusted argument array passed to the next plugin or the original method
*/
public function beforeGetFinalPrice(Product $subject, float $basePrice): array
{
return [$basePrice]; // could adjust the argument here before it reaches the original method
}
/**
* Wraps the original method call. Lower sortOrder plugins wrap outside higher sortOrder plugins.
*
* @param Product $subject The intercepted product instance
* @param Closure $proceed Calls the next plugin in the chain, or the original method
* @param float $basePrice Argument forwarded to the wrapped call
* @return float Final result returned to the caller or to the next outer around-plugin
*/
public function aroundGetFinalPrice(Product $subject, Closure $proceed, float $basePrice): float
{
$result = $proceed($basePrice);
return $result; // could adjust the result here, wrapping the inner chain
}
/**
* Runs after the original method, in descending sortOrder order among all after-plugins.
*
* @param Product $subject The intercepted product instance
* @param float $result Result produced by the original method or the inner plugin chain
* @return float Final adjusted result
*/
public function afterGetFinalPrice(Product $subject, float $result): float
{
return $result; // could adjust the final result here
}
}
4. Conflict scenario: two modules patch the same method
A realistic conflict scenario: a discount module Vendor\Discount registers an around plugin on getFinalPrice() that subtracts a percentage discount. Independently, a tax module Vendor\Tax also registers an around plugin on the same method that adds a tax. Functionally correct would be: apply tax to the net price first, then apply the discount to the gross price, or the other way around depending on the business model, but in any case in a deliberate, defined order.
If both modules register their plugin without an explicit sortOrder, the module load order alone decides which of the two takes effect first, and this order can shift unnoticed after a Composer update, a new module version, or even a seemingly harmless change to a third, unrelated module.xml. The result: a price that was correct yesterday suddenly deviates by a few cents after a deployment, without a single line of code changing in either of the two involved plugins. This is the classic, hard-to-reproduce plugin sort order conflict that almost never shows up in code reviews because both plugins look completely correct in isolation.
5. Diagnosis with dev:di:info
The first step whenever a plugin sort order conflict is suspected is bin/magento dev:di:info <Class>. The command lists every plugin registered on a class, including its actual sortOrder value and the final computed execution order, before you even have to open a single line of source code. That's significantly faster than a manual search through every di.xml file in the project, especially when a third-party module is involved whose source code you don't know by heart.
bin/magento dev:di:info "Magento\Catalog\Model\Product"
# Example output showing the conflict:
# Plugins for Magento\Catalog\Model\Product::getFinalPrice:
# plugin_name sortOrder instance
# ------------------------------------ ---------- ----------------------------------------
# vendor_discount_final_price 10 Vendor\Discount\Plugin\ProductPricePlugin
# vendor_tax_final_price 10 Vendor\Tax\Plugin\ProductPricePlugin
#
# WARNING: two plugins share the same sortOrder (10) on the same method.
# Effective execution order for tied values is determined by module
# sequence in app/etc/config.php, not by di.xml alone.
# Narrow the check to a single plugin type for a focused diagnosis
bin/magento dev:di:info "Magento\Catalog\Model\Product" | grep -A2 "final_price"
Two things can be derived immediately from this output: first, whether multiple plugins are even registered on the same method, and second, whether identical or missing sortOrder values create an unclear order. In practice, dev:di:info is the first command to run whenever a plugin sort order conflict is suspected, even before reading the source code of individual plugins.
6. Fixing it without touching foreign code
The obvious but wrong reflex is to edit the foreign module's di.xml directly in the vendor directory. That works until the next composer update, after which the change is lost and the conflict returns without a trace. The correct path goes through your own small module that registers an additional plugin with a deliberately chosen sortOrder, combined with a <sequence> declaration in your own module.xml that references both foreign modules. This sequence doesn't directly influence the sortOrder values themselves, but it does influence the order in which Magento merges the di.xml files during compilation, and thereby the deterministic resolution of ties with identical sortOrder.
For the concrete example from section 4, this means: a custom module Vendor\PriceOrderFix registers an additional, own around plugin with a lower sortOrder than both foreign modules, which enforces the desired execution order by wrapping around both foreign modules and orchestrating their calculations in the functionally correct order, instead of relying on the random module load order.
<!-- Vendor/PriceOrderFix/etc/module.xml: loads after both conflicting vendor modules -->
<module name="Vendor_PriceOrderFix">
<sequence>
<module name="Vendor_Discount"/>
<module name="Vendor_Tax"/>
</sequence>
</module>
<!-- Vendor/PriceOrderFix/etc/di.xml: enforces a defined order with the lowest sortOrder -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Model\Product">
<plugin name="vendor_price_order_fix" type="Vendor\PriceOrderFix\Plugin\PriceOrderFixPlugin" sortOrder="1"/>
</type>
</config>
This fix is deployment-safe because it lives entirely inside your own module, and a Composer update of the third-party package cannot destroy the solution. The price for this is an additional small module in the project whose sole purpose is conflict resolution, an acceptable trade-off given the alternative of directly touching vendor code.
7. Using disable="true" deliberately
A second technique for conflict resolution is disable="true" in your own <plugin> element, which disables a foreign module's plugin for your own di.xml declaration without altering its code. This makes sense when one of the two competing plugins simply shouldn't run in a certain context, for example because your own, customer-specific pricing logic should completely replace the foreign module's default calculation instead of merely reordering it.
The risk of this approach: a later update of the foreign module can change the name value of the disabled plugin, causing the disable="true" declaration to have no effect without Magento reporting an error; the disabled plugin then simply runs again. Every disable="true" declaration should therefore be documented with a comment referencing the specific module version it was tested against, and re-verified in automated tests after every Composer update.
<!-- Disables a third-party plugin entirely, tested against vendor/tax-module 2.3.1 -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Model\Product">
<plugin name="vendor_tax_final_price" disable="true"/>
</type>
</config>
8. Conventions to prevent future conflicts
The most effective measure against future plugin sort order conflicts is a team-wide or project-wide convention for sortOrder values. Instead of assigning values like 1, 2, 3, a grid with gaps is recommended, for example 10, 20, 30, so a later additional plugin with an intermediate value like 15 can be inserted without having to adjust existing values. Larger agencies additionally reserve entire number ranges per module family, for example 100 to 199 for all pricing calculation plugins and 200 to 299 for all validation plugins, so a plugin's rough category is readable from the sortOrder value alone.
Equally important is documenting the deliberate decision in an architecture decision record, or at least a detailed comment directly in the di.xml, whenever a specific order is functionally mandatory. A comment like "Must run before tax calculation, otherwise the discount would incorrectly be applied to the gross price" saves the next developer, who touches this spot years later, exactly the debugging time that would otherwise be needed to re-analyze it from scratch.
9. Comparison: risky vs. robust approaches
The overview below summarizes which approaches to handling plugin sort order conflicts are risky and which have proven robust in practice.
| Situation | Risky approach | Robust approach | Advantage |
|---|---|---|---|
| No sortOrder set | Relying on module load order | Always set an explicit sortOrder | Order independent of Composer updates |
| Foreign module conflict | Patching di.xml directly in vendor | Own module with sequence and sortOrder | Survives composer update |
| Assigning sortOrder | Numbering sequentially 1, 2, 3 | Leaving gaps: 10, 20, 30 | Room for later insertions |
| Diagnosis on suspicion | Manually searching every di.xml | bin/magento dev:di:info Class | Complete chain in seconds |
| Disabling a foreign plugin | disable=true without documentation | disable=true with version comment | Detectable on foreign module updates |
The common denominator of all robust approaches: control over plugin sort order lives explicitly in your own, versioned code, instead of depending implicitly on a random module load order or unmodified third-party files.
10. Summary
Plugin sort order in Magento 2 follows a deterministic but easily overlooked model: before methods run ascending by sortOrder, around methods nest like onion layers with the lowest value outside, after methods run descending. Conflicts arise almost always when multiple modules patch the same method without an explicit sortOrder and unknowingly rely on module load order. bin/magento dev:di:info is the central diagnostic command to uncover such conflicts in seconds instead of through manual code searching.
Foreign module conflicts can be reliably resolved via your own small module with a matching sequence and a targeted sortOrder, without modifying vendor code and thereby risking a Composer update. Anyone who additionally establishes project-wide conventions for sortOrder ranges and documents deliberate ordering decisions significantly reduces the likelihood of future plugin sort order conflicts, instead of discovering them only after the next deployment in production.
Magento 2 Plugin Sort Order: The Essentials at a Glance
Execution model
before ascending, around nested with the lowest sortOrder outside, after descending by sortOrder.
Diagnosis
bin/magento dev:di:info Class shows every plugin with sortOrder and execution order instantly.
Fix without foreign code
Register an own module with sequence on both conflicting modules plus a lower own sortOrder.
Convention
Assign sortOrder values with gaps (10, 20, 30) and document ordering decisions in the code.
11. FAQ: Magento 2 Plugin Sort Order
1What happens without sortOrder?
2Order of before methods?
3Nesting of around methods?
4Why do conflicts often go unnoticed?
5Fastest diagnosis?
6Allowed to patch vendor di.xml?
7What is disable=true for?
8Why leave gaps in sortOrder?
9Does sequence directly affect sortOrder?
10How to document ordering decisions?
Mironsoft
Magento plugin architecture, conflict diagnosis and long-term stable interceptor chains
Unexplained price or validation errors after deployment?
We analyze existing plugin chains, uncover sortOrder conflicts between your modules and third-party extensions, and resolve them in a deployment-safe way without touching vendor code.
Conflict diagnosis
Systematically analyze every plugin chain on critical core classes
Third-party fixes
Resolve sortOrder conflicts through your own deployment-safe extension modules
Conventions
Establish team-wide sortOrder ranges and documentation standards