secured with PHPUnit tests
Multiple plugins on the same method silently depend on the sortOrder in di.xml. A test that documents the actual execution order prevents a new plugin from silently tipping over an existing order and, with it, the behavior.
Table of Contents
- 1. Why plugin order is invisible but critical behavior
- 2. An example scenario with three competing plugins
- 3. Verifying the actual execution order with an integration test
- 4. Building a simple execution recorder as a reusable test building block
- 5. Alternative: checking order indirectly through the final result
- 6. Regression protection when adding a fourth plugin
- 7. Accounting for the specifics of mixed before and after plugins
- 8. Using the test simultaneously as readable documentation of the order
- 9. Placing plugin order tests strategically in the test suite
- 10. Summary
- 11. FAQ
1. Why plugin order is invisible but critical behavior
Magento's plugin system allows multiple modules to intercept the same method of the same class, controlled exclusively by the sortOrder in their respective di.xml files. This order is not documented anywhere centrally visible; it emerges implicitly from the sum of all modules touching the same method. For 'before' plugins, a lower sortOrder means earlier execution, while for 'after' plugins the effect on the final result is effectively reversed, since later-executed 'after' plugins can overwrite the return value of earlier ones.
The risk becomes concrete as soon as a second team or a third module registers another plugin on the same method. Without deliberate coordination, the sortOrder lands somewhere at random, and a plugin that was supposed to run before the price calculation suddenly runs after it. The result is a bug that cannot be explained by faulty code in a single plugin, but only by the interplay of multiple plugins in the wrong order, a failure pattern that is particularly tedious to debug.
2. An example scenario with three competing plugins
As an illustration, consider a price calculation service with three plugins: a discount plugin that subtracts a percentage discount, a tax plugin that adds VAT, and a rounding plugin that rounds the final price to two decimal places. The functionally correct order is clear: discount first, then tax, then rounding last. If rounding ran before the tax calculation, double rounding would introduce small but real cent deviations in the final price.
In di.xml, this order is fixed through three sortOrder values, for example 10 for discount, 20 for tax, and 30 for rounding. On their own, however, these numbers tell a new developer nothing about the business necessity of this order; they look like arbitrary numbering. This is exactly where a test comes in, making the actual execution order explicit and thereby also documenting the business rationale.
<?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="Mironsoft\Pricing\Model\PriceCalculator">
<plugin name="mironsoft_discount" type="Mironsoft\Pricing\Plugin\ApplyDiscountPlugin" sortOrder="10" />
<plugin name="mironsoft_tax" type="Mironsoft\Pricing\Plugin\ApplyTaxPlugin" sortOrder="20" />
<plugin name="mironsoft_rounding" type="Mironsoft\Pricing\Plugin\RoundPricePlugin" sortOrder="30" />
</type>
</config>
3. Verifying the actual execution order with an integration test
Since plugin wiring is an interceptor mechanism carried by Magento's generated code, the real order can only be reliably verified through an integration test that calls the actual class instance produced by the object manager. A unit test with a directly instantiated class would bypass the plugins entirely and thus fail to test exactly the thing that matters.
The trick for a meaningful order test is a logging mechanism: each plugin records its own name into a shared log as it runs, for example via an injected logger object that can be swapped out in tests. After calling the method, the test checks whether the names appear in the log in exactly the expected order. That turns the execution order into an explicit, versioned test assertion instead of a silent assumption spread across three separate di.xml files.
<?php
declare(strict_types=1);
namespace Mironsoft\Pricing\Test\Integration\Model;
use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Pricing\Model\PriceCalculator;
use Mironsoft\Pricing\Test\Fixture\PluginExecutionRecorder;
use PHPUnit\Framework\TestCase;
class PriceCalculatorPluginOrderTest extends TestCase
{
public function testPluginsRunInDiscountTaxRoundingOrder(): void
{
$objectManager = Bootstrap::getObjectManager();
PluginExecutionRecorder::reset();
/** @var PriceCalculator $calculator */
$calculator = $objectManager->create(PriceCalculator::class);
$calculator->calculate(100.0);
self::assertSame(
['mironsoft_discount', 'mironsoft_tax', 'mironsoft_rounding'],
PluginExecutionRecorder::getExecutionOrder()
);
}
}
4. Building a simple execution recorder as a reusable test building block
The execution recorder from the previous example is deliberately kept simple: a static class with an array that each participating plugin appends its name to as it runs. Static state should generally be treated with caution in unit tests, but for this very narrowly scoped purpose, logging an order within a single test run, it is a pragmatic and easy-to-understand tool.
It is important to explicitly reset the recorder before every test, so results from previous tests do not leak incorrectly into the current one. Each plugin calls the recorder in its beforeCalculate() or afterCalculate() method, which causes no overhead at all in production if the recorder call is only active in the test context or gated behind a null-object pattern.
<?php
declare(strict_types=1);
namespace Mironsoft\Pricing\Test\Fixture;
class PluginExecutionRecorder
{
/** @var string[] */
private static array $order = [];
public static function reset(): void
{
self::$order = [];
}
public static function record(string $pluginName): void
{
self::$order[] = $pluginName;
}
/**
* @return string[]
*/
public static function getExecutionOrder(): array
{
return self::$order;
}
}
5. Alternative: checking order indirectly through the final result
Not every team wants to add a dedicated recorder to production code, even if it is only active in a test context. An alternative is to check the order indirectly through the functionally correct final result: for a price of one hundred euros, a ten percent discount, and nineteen percent tax, the correct order (discount first, then tax) produces a different final result than the wrong order (tax first, then discount).
This approach is less explicit than the execution recorder, because a failing test does not immediately show which two plugins were swapped, only that something about the calculation is wrong. It has the advantage, however, of needing no test infrastructure at all in production code, while at the same time checking that the calculation as a whole is functionally correct, not merely that the order is formally right.
<?php
declare(strict_types=1);
namespace Mironsoft\Pricing\Test\Integration\Model;
use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Pricing\Model\PriceCalculator;
use PHPUnit\Framework\TestCase;
/**
* @magentoConfigFixture default_store mironsoft_pricing/discount/percent 10
* @magentoConfigFixture default_store tax/calculation/rate 19
*/
class PriceCalculatorResultOrderTest extends TestCase
{
public function testDiscountAppliedBeforeTaxProducesExpectedTotal(): void
{
$objectManager = Bootstrap::getObjectManager();
/** @var PriceCalculator $calculator */
$calculator = $objectManager->create(PriceCalculator::class);
$result = $calculator->calculate(100.0);
// 100 - 10% discount = 90.00, then + 19% tax = 107.10
self::assertSame(107.10, $result);
}
}
6. Regression protection when adding a fourth plugin
The real value of an order test shows up as soon as another team registers a fourth plugin on the same method, for example a loyalty points plugin that calculates additional points based on the final price. Without an order test, a wrongly chosen sortOrder that accidentally places the loyalty plugin before rounding might only surface in production, when point calculations deviate slightly from the unrounded instead of the rounded prices.
With an existing order test, the test fails immediately as soon as the new plugin is registered, because the expected order in the assertion no longer matches the actual one. The developer of the new plugin is thereby forced to choose the sortOrder deliberately and update the test explicitly, instead of randomly slotting into the chain somewhere. The test thus becomes an active communication channel between teams that otherwise know nothing about each other.
<?php
declare(strict_types=1);
namespace Mironsoft\Pricing\Test\Integration\Model;
use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Pricing\Model\PriceCalculator;
use Mironsoft\Pricing\Test\Fixture\PluginExecutionRecorder;
use PHPUnit\Framework\TestCase;
class PriceCalculatorPluginOrderWithLoyaltyTest extends TestCase
{
public function testLoyaltyPluginRunsAfterRounding(): void
{
$objectManager = Bootstrap::getObjectManager();
PluginExecutionRecorder::reset();
/** @var PriceCalculator $calculator */
$calculator = $objectManager->create(PriceCalculator::class);
$calculator->calculate(100.0);
self::assertSame(
['mironsoft_discount', 'mironsoft_tax', 'mironsoft_rounding', 'mironsoft_loyalty_points'],
PluginExecutionRecorder::getExecutionOrder()
);
}
}
7. Accounting for the specifics of mixed before and after plugins
The order becomes more complex once both 'before' and 'after' plugins are active on the same method, since Magento applies sortOrder ascending for 'before' plugins, but for 'after' plugins effectively in the order they pass the return value along, meaning a lower sortOrder forms the inner wrapper and a higher sortOrder the outer one. A test that only looks at 'around' plugins does not cover this behavior.
For mixed scenarios, it is worth having the recorder explicitly log whether an entry came from a 'before' or 'after' method, for example as 'mironsoft_discount:before' and 'mironsoft_discount:after'. That makes the test show the order of both entry into and exit from each plugin, which matters especially for plugins that modify the return value afterward.
8. Using the test simultaneously as readable documentation of the order
Beyond pure regression protection, an order test has a second value: it is the one place in the code where the business rationale for a particular plugin order stands side by side as a comment and as an assertion. While the sortOrder values in the various di.xml files carry no justification on their own, the test's docblock can explain exactly why discount must run before tax and tax before rounding.
This dual role makes the test a valuable onboarding tool for new team members: instead of searching through three different di.xml files in three different modules to understand the overall logic, a glance at the test file immediately delivers the complete chain along with the rationale. This property justifies the effort of the order test even when a team currently has no acute order bugs.
9. Placing plugin order tests strategically in the test suite
Since plugin order tests are necessarily integration tests, they need the real, generated interceptor, they are slower than pure unit tests, but still noticeably faster than a full end-to-end test through the REST API. They belong in the integration test phase of the CI pipeline and should run on every change to a di.xml file with affected plugins.
A pragmatic approach is to create order tests deliberately for all methods where three or more plugins from different modules are active, since the risk of an unintended sortOrder collision is highest there. For methods with only a single plugin, such a test is unnecessary effort, since by definition there is no order to test there.
| Test approach | What it shows | Advantage | Disadvantage |
|---|---|---|---|
| Execution recorder | Exact order of all plugin calls | Immediately shows which two plugins were swapped | Needs an extra test building block in the code |
| Result-based test | Functionally correct final calculation result | No test infrastructure needed in production code | Failure cause is less specific when it fails |
| Before/after logged separately | Entry and exit order for mixed plugin types | Also covers around and after wrapper behavior | Slightly more effort in recorder setup |
| Data provider with multiple starting values | Order correctness across multiple inputs | Also covers rounding and edge cases | More test cases to maintain |
Mironsoft
Test automation, Magento quality assurance, and CI integration
Tests that catch real bugs instead of just turning green?
We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.
Test Audit
Reviewing existing suites for mocking antipatterns and blind spots.
Test Strategy
Meaningfully combining unit, integration, and MFTF tests for Magento projects.
CI Integration
Setting up fast, reliable test runs in GitLab CI or GitHub Actions.
10. Summary
Plugin Order Testing: Key Takeaways
Core idea
Make the actual plugin execution order explicit through an integration test with an execution recorder.
Why a unit test is not enough
Plugins only take effect through the generated interceptor, which a directly instantiated object bypasses.
Extra benefit
The test simultaneously documents the business rationale for the order for new team members.
Especially important when
Three or more plugins from different modules act on the same method.