Securing Plugin Order and sortOrder with Tests
AI generated
@test
assert
PHPUnit · Magento · Plugins
Plugin Order and sortOrder
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.

14 min read di.xml sortOrder Interceptor Plugin chains

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.

11. FAQ: Plugin Order Testing: Key Takeaways

1Why is a unit test not enough to check plugin order?
Because plugins only take effect through Magento's generated interceptor. A unit test with a directly instantiated class bypasses this mechanism entirely and thus does not test the plugins at all.
2How do I log the actual execution order of multiple plugins?
Through a simple execution recorder that each plugin calls with its own name as it runs. The test then checks whether the logged order matches the expected order.
3What happens if a new plugin unintentionally changes the sortOrder sequence?
Without a test this often only surfaces in production, for example through tiny price deviations. With an order test, the test fails immediately as soon as the new plugin is registered.
4How does order differ between before and after plugins?
For before plugins, a lower sortOrder means earlier execution. For after plugins, a higher sortOrder affects the outer wrapper, effectively reversing the behavior compared to before plugins.
5Is a result-based test a good alternative to the execution recorder?
Yes, if no additional test infrastructure is wanted in production code. It checks indirectly through the functionally correct final result, but on failure does not directly show which two plugins were swapped.
6Should I write an order test for every method with only one plugin?
No, with only one plugin there is by definition no order to test. Order tests are worthwhile specifically when three or more plugins from different modules are involved.
7How do I handle mixed before and after plugins in an order test?
The recorder should explicitly log whether an entry came from the before or the after method, to make both entry and exit order visible.
8Do plugin order tests belong in the fast unit test phase of the CI pipeline?
No, since they need the real generated interceptor, they are necessarily integration tests. They belong in the integration test phase, but remain noticeably faster than a full end-to-end test.
9What value does an order test provide beyond pure bug prevention?
It documents the business rationale for the order in one central place and serves new team members as a quick overview of how multiple modules interact.
10Do I need to reset the execution recorder before every test?
Yes, otherwise results from previous tests can incorrectly leak into the current test, especially with static state that persists across test cases.