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
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.
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
- 2. Before Plugin: modifying arguments
- 3. After Plugin: modifying the return value
- 4. Around Plugin: wrapping a method
- 5. Plugin registration in di.xml
- 6. sortOrder: controlling plugin order
- 7. Plugin chain: multiple plugins on one method
- 8. Limitations: when plugins don't work
- 9. Practical examples from everyday Magento work
- 10. Plugin vs. Preference vs. Observer
- 11. Summary
- 12. FAQ
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.
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
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.
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?
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?
$proceed is not called.3 Can I create plugins for my own classes?
4 What is the difference between Before, After and Around?
$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?
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?
$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?
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?
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?
getById inside loops) can become noticeable.