Plugin / Interceptor Pattern in Magento 2: Before, After, Around Explained | Mironsoft
AI generated

Plugin / Interceptor Pattern in Magento 2: Before, After and Around Explained

· Reading time: approx. 15 minutes · Part of the series: Design Patterns in Magento 2

Hook
Plugin
Design Pattern #3 · Behavioral (Magento-specific)

Plugin / Interceptor
Pattern in Magento 2

Before, After, Around: extend Magento methods without a core override. Plugin chains, sortOrder, limitations and the most important pitfalls fully explained.

⏱ 15 min. PHP 8.4 Upgrade-safe No core override

The Plugin Pattern: Magento's Unique Extension Mechanism

The Plugin Pattern, also called the Interceptor Pattern in Magento 2, is arguably the most Magento-specific of all design patterns. It solves a problem that is unavoidable in large systems with many modules installed at the same time: how can multiple modules extend the same behavior of a class without overwriting each other?

The classic solution, replacing a class with a custom subclass (Preference / Override), fails here because only one class can be active at a time. Magento 2 solves this problem with an automatically generated proxy mechanism: for every class that has plugins, Magento generates an Interceptor class that calls all registered plugins in a chain.

1. How Plugins Work Internally

When bin/magento setup:di:compile runs, Magento generates an Interceptor class for every class that has plugins. This class inherits from the original class and overrides all affected methods. The generated class lives under generated/code/.

Instead of the original class, the Interceptor class is always the one instantiated. The Interceptor class calls all registered Before plugins, then the original method, then all After plugins, in the configured order (sortOrder). Around plugins wrap this entire call.


<?php
// Simplified generated Interceptor, what Magento generates automatically:
// generated/code/Magento/Catalog/Api/ProductRepositoryInterface/Interceptor.php

class Interceptor extends ProductRepository
{
    public function getById(int $productId, bool $editMode = false): ProductInterface
    {
        // 1. Run all Before-Plugins
        $pluginInfo = $this->pluginList->getNext($this->subjectType, 'getById');
        if (!$pluginInfo) {
            return parent::getById($productId, $editMode);
        }

        // 2. Call plugin chain (before → original → after)
        return $this->___callPlugins('getById', func_get_args(), $pluginInfo);
    }
}

2. Before Plugin: Modifying Arguments

A Before plugin runs before the original method. The plugin method is named before + MethodName (PascalCase). It receives the subject (the original object) and the arguments of the original method. The return value is an array with the (possibly modified) arguments.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;

/**
 * Before Plugin: Normalizes product SKU to uppercase before any repository lookup.
 */
class NormalizeSkuPlugin
{
    /**
     * Intercepts ProductRepository::get() before it executes.
     *
     * @return array modified arguments [string $sku]
     */
    public function beforeGet(
        ProductRepositoryInterface $subject,
        string $sku,
        bool $editMode = false,
        ?int $storeId = null,
        bool $forceReload = false
    ): array {
        // Return array of modified arguments, order must match original method signature
        return [strtoupper(trim($sku)), $editMode, $storeId, $forceReload];
    }

    /**
     * Intercepts ProductRepository::save() before it executes.
     * Example: enforce SKU format before saving.
     *
     * @return array|null return null to NOT modify arguments
     */
    public function beforeSave(
        ProductRepositoryInterface $subject,
        \Magento\Catalog\Api\Data\ProductInterface $product
    ): ?array {
        if (!$product->getSku()) {
            // Auto-generate SKU if missing
            $product->setSku('AUTO-' . uniqid());
        }

        return [$product]; // return modified argument
    }
}

Important: Before plugins must return an array (with the possibly modified arguments) or null if no change is made. The order in the array must match the original method signature.

3. After Plugin: Modifying the Return Value

An After plugin runs after the original method. The plugin method is named after + MethodName. It receives the subject, the return value of the original method ($result) and optionally the arguments of the original method (since Magento 2.2).


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;

/**
 * After Plugin: Enriches product data with additional information after loading.
 */
class EnrichProductDataPlugin
{
    public function __construct(
        private readonly \Mironsoft\Catalog\Service\StockBadgeService $stockBadgeService
    ) {}

    /**
     * Intercepts ProductRepository::getById() after it completes.
     *
     * @param ProductInterface $result the return value of the original method
     * @return ProductInterface potentially modified result
     */
    public function afterGetById(
        ProductRepositoryInterface $subject,
        ProductInterface $result,
        int $productId,          // Original method arguments available since Magento 2.2
        bool $editMode = false
    ): ProductInterface {
        // Add custom data to the returned product object
        $hasBadge = $this->stockBadgeService->productHasSaleBadge($result);
        $result->setCustomAttribute('has_sale_badge', $hasBadge ? '1' : '0');

        return $result;
    }

    /**
     * After Plugin on getList: filter out out-of-stock items for guests.
     */
    public function afterGetList(
        ProductRepositoryInterface $subject,
        \Magento\Catalog\Api\Data\ProductSearchResultsInterface $result
    ): \Magento\Catalog\Api\Data\ProductSearchResultsInterface {
        // Filter items in the search results
        $filteredItems = array_filter(
            $result->getItems(),
            fn(ProductInterface $p) => $p->isSaleable()
        );
        $result->setItems(array_values($filteredItems));

        return $result;
    }
}

4. Around Plugin: Fully Wrapping a Method

An Around plugin wraps the entire method, including all other plugins. It decides for itself whether and how the original method (or the next plugin stage) is called. The plugin method is named around + MethodName and receives, as its second argument, a callable $proceed that represents the next step in the chain.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Psr\Log\LoggerInterface;

/**
 * Around Plugin: Adds timing/profiling around product repository calls.
 * Use Around only when you need to control whether the original executes.
 */
class ProfileProductLoadPlugin
{
    public function __construct(
        private readonly LoggerInterface $logger
    ) {}

    /**
     * Around Plugin for ProductRepository::getById().
     *
     * @param callable $proceed calls the next plugin or the original method
     */
    public function aroundGetById(
        ProductRepositoryInterface $subject,
        callable $proceed,
        int $productId,
        bool $editMode = false,
        ?int $storeId = null,
        bool $forceReload = false
    ): ProductInterface {
        $start = microtime(true);

        try {
            // MUST call $proceed() to execute the original (and other plugins)!
            $result = $proceed($productId, $editMode, $storeId, $forceReload);
        } catch (\Exception $e) {
            // Around plugin can also catch and handle exceptions
            $this->logger->error('Product load failed', [
                'product_id' => $productId,
                'error' => $e->getMessage(),
            ]);
            throw $e; // rethrow, don't swallow exceptions silently
        }

        $duration = microtime(true) - $start;
        if ($duration > 0.1) {
            $this->logger->warning('Slow product load', [
                'product_id' => $productId,
                'duration_ms' => round($duration * 1000, 2),
            ]);
        }

        return $result;
    }
}

Warning: Around plugins that do not call $proceed() interrupt the entire plugin chain. That is sometimes intentional (e.g. caching, feature flags), but dangerous: other modules in the chain no longer run. Use Around plugins sparingly, Before and After are sufficient in most cases.

5. Plugin Registration in di.xml


<!-- app/code/Mironsoft/Catalog/etc/di.xml -->
<?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\Api\ProductRepositoryInterface">

        <!-- Before Plugin: normalize SKU -->
        <plugin name="mironsoft_normalize_sku"
                type="Mironsoft\Catalog\Plugin\NormalizeSkuPlugin"
                sortOrder="10"/>

        <!-- After Plugin: enrich product data -->
        <plugin name="mironsoft_enrich_product"
                type="Mironsoft\Catalog\Plugin\EnrichProductDataPlugin"
                sortOrder="20"/>

        <!-- Around Plugin: profiling (disabled in production via config) -->
        <plugin name="mironsoft_profile_product_load"
                type="Mironsoft\Catalog\Plugin\ProfileProductLoadPlugin"
                sortOrder="100"
                disabled="true"/>

    </type>

    <!-- Plugin on a specific scope only (frontend) -->
    <!-- In etc/frontend/di.xml: -->
    <!-- <type name="..."><plugin name="..." type="..." sortOrder="..."/></type> -->
</config>

6. sortOrder: Precisely Controlling Plugin Order

The sortOrder attribute determines the order in which multiple plugins on the same method are executed. Smaller numbers run first. This is especially important when plugins depend on each other, or when one plugin could undo a change made by another.

Plugin chain execution order at sortOrder 10, 20, 100 before (10) NormalizeSku before (20) AutoSku Original Method after (20) EnrichProduct after (100) Profiler Before plugins: smallest sortOrder first After plugins: smallest sortOrder first

7. Plugin Chain: Multiple Plugins on One Method

Multiple modules can register plugins on the same method. Magento runs all of them, in the order of their sortOrder. No module needs to know whether other modules have also registered plugins.


<?php
// Plugin A from Module 1 (sortOrder=10):
public function beforeSave(ProductRepositoryInterface $subject, ProductInterface $product): array
{
    $product->setData('enriched_by_module_a', true);
    return [$product];
}

// Plugin B from Module 2 (sortOrder=20) sees the change made by Plugin A:
public function beforeSave(ProductRepositoryInterface $subject, ProductInterface $product): array
{
    if ($product->getData('enriched_by_module_a')) {
        $product->setData('double_enriched', true);
    }
    return [$product];
}

// Execution order:
// 1. Plugin A before (sortOrder=10)
// 2. Plugin B before (sortOrder=20)
// 3. Original save() method
// 4. Plugin B after (sortOrder=20), After order: lowest first
// 5. Plugin A after (sortOrder=10)

8. Limitations: When Plugins Don't Work

Plugins have clear boundaries. It's important to know them so you don't waste time on a plugin that will never fire:

  • Final classes (final class): Cannot be extended by interceptors, so no plugin is possible.
  • Final methods (final function): Cannot be overridden, so no plugin is possible.
  • Private methods: Private methods are not part of the class API, so no plugin.
  • Static methods: Static methods are not intercepted.
  • Constructors: __construct() cannot be plugged.
  • Direct ObjectManager calls: Classes instantiated directly via the ObjectManager (without DI) cannot have plugins.

<?php
// These methods CANNOT be plugged:

final class UnpluggableClass  // final class → no plugin
{
    public function doSomething(): void {}
}

class SomeClass
{
    final public function alsoUnpluggable(): void {}  // final method → no plugin
    private function alsoNotPluggable(): void {}       // private → no plugin
    public static function staticNotPluggable(): void {} // static → no plugin
}

// Solution when a plugin doesn't work:
// → use an Observer/Event
// → Preference (last resort)
// → refactor the class (if it's your own code)

9. Practical Examples from Everyday Magento Work

Example 1: Clearing the Product URL Cache After Saving (Cache Invalidation)


<?php
namespace Mironsoft\SeoTools\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\App\Cache\TypeListInterface;

class InvalidateProductUrlCachePlugin
{
    public function __construct(
        private readonly TypeListInterface $cacheTypeList
    ) {}

    /**
     * After saving a product, invalidate the URL and FPC cache.
     */
    public function afterSave(
        ProductRepositoryInterface $subject,
        ProductInterface $result
    ): ProductInterface {
        // Invalidate Full Page Cache after product save
        $this->cacheTypeList->invalidate(['full_page', 'block_html']);
        return $result;
    }
}

Example 2: Preventing Guest Orders (Feature Flag)


<?php
namespace Mironsoft\B2B\Plugin;

use Magento\Checkout\Model\Type\Onepage;
use Magento\Customer\Model\Session;

class RequireLoginForCheckoutPlugin
{
    public function __construct(
        private readonly Session $customerSession,
        private readonly \Mironsoft\B2B\Helper\Config $config
    ) {}

    /**
     * Around Plugin: prevent guest checkout when B2B mode is enabled.
     */
    public function aroundGetCheckoutMethod(
        Onepage $subject,
        callable $proceed
    ): string {
        if ($this->config->isB2BModeEnabled() && !$this->customerSession->isLoggedIn()) {
            // Return 'login_in' to redirect to login, do NOT call $proceed()
            return Onepage::METHOD_REGISTER;
        }

        return $proceed();
    }
}

10. Plugin vs. Preference vs. Observer: The Decision Matrix

Plugin vs. Preference vs. Observer: when to use which? Criterion Plugin Observer Preference Change return value Modify arguments Multiple modules simultaneously ✗ (only 1) Upgrade-safe ✗ (risky) No event required ✗ (needs an event)

Mironsoft

Magento 2 Module Development

Time to Clean Up Your Magento Core Overrides?

Replace existing preferences and core overrides with clean plugins, for upgrade-safe Magento 2 extensions without conflicts.

Plugin Audit
Analyze existing core overrides and preferences, and replace them with clean plugins.
Plugin Migration
Convert preference-based modules to Before/After plugins for upgrade safety.
Plugin Tests
PHPUnit integration tests for plugin chains with complete coverage of all scenarios.

11. Summary

The Plugin Pattern is Magento's most powerful extension mechanism. Before, After and Around plugins make it possible to extend the behavior of any public method, without editing core files, without blocking other modules and without upgrade risk.

Plugin Pattern in Magento 2: Rules at a Glance

Before Plugin

Modifies arguments. Returns an array with the arguments (or null for no change). Method name: before + MethodName.

After Plugin

Modifies the return value. Second argument is $result. Returns the modified result. Method name: after + MethodName.

Around Plugin

Always call $proceed() unless you deliberately want to abort. Second argument is callable $proceed. Use sparingly.

Limitations

Not possible on: final class, final method, private method, static method, __construct. Alternative: Observer or Preference.

12. FAQ: Plugin Pattern in Magento 2

1 Why isn't my plugin working?
Checklist: (1) bin/magento setup:di:compile && cache:flush. (2) Is the method not final/private/static? (3) Is the method name correct? (beforeGet, not before_get). (4) Is the plugin not marked disabled="true"? (5) Is the module active? (6) Is the class instantiated via DI (not via new)?
2 Around plugin without $proceed: what happens?
The original method and all subsequent plugins are not executed. Sometimes this is intentional (feature flag, caching), but it's dangerous: other modules in the chain get skipped. Always use it deliberately and document explicitly why $proceed is not called.
3 Can I create plugins for my own classes?
Yes! Plugins work not only on core classes but on any class, including your own. This makes it possible, for example, to build an "audit plugin" that logs every repository save without touching the repository code. The only condition: the class must be instantiated via DI.
4 What is the difference between Before, After and Around?
Before: runs first, can change arguments (return an array). After: runs after the method, receives $result and can change it. Around: wraps everything, $proceed() continues the chain. Rule of thumb: Before/After are enough for 90% of cases.
5 Can I register plugins on interfaces instead of classes?
Yes, and this is the preferred approach. A plugin on ProductRepositoryInterface applies to every implementation. This is more stable than using a concrete class name, which can change through preferences. In di.xml: <type name="Magento\Catalog\Api\ProductRepositoryInterface">.
6 How do I test a plugin with PHPUnit?
Call the plugin method directly: use a subject mock; for After plugins, pass a $result mock. For Around plugins, mock $proceed as a closure and check whether it was called. No DI compile needed, instantiate the plugin class directly with mocked dependencies.
7 What happens when two modules register the same plugin?
Both run, that is the design. sortOrder determines the order. Plugin names must be unique: identical names overwrite each other. Recommendation: unique names with a module prefix (vendor_module_pluginname).
8 Can I disable a plugin without changing code?
Yes, register the plugin name with disabled="true" in your own module's di.xml. This also works for plugins from other modules. Scope-specific: disable it only for the frontend in etc/frontend/di.xml.
9 How do I find active plugins on a method?
bin/magento dev:di:info 'Magento\Catalog\Api\ProductRepositoryInterface' lists all plugins. Alternatively: read the generated Interceptor file (generated/code/). Or: grep -r 'plugin name' app/code/ --include="di.xml".
10 Are there performance differences between plugin types?
Around plugins have the highest overhead (closure chain). Before/After are lighter. In practice the difference is minimal, the actual factor is the logic inside the plugin. Caution: many Around plugins on frequently called methods (e.g. getById inside loops) can become noticeable.