Learning Objectives
By completing this tutorial, you will master Magento 2's plugin system and understand how to extend functionality without modifying core or third-party code. You'll learn when to use plugins versus alternatives, avoid common pitfalls, and build production-ready interceptors.
What you'll accomplish:
- Understand the plugin (interceptor) pattern and implementation
- Master Before, After, and Around plugin types with real-world examples
- Control plugin execution order using sortOrder and dependencies
- Choose the correct extension mechanism: plugins vs observers vs preferences
- Avoid common plugin pitfalls that break functionality or performance
- Optimize plugin performance and minimize generated code bloat
Introduction
Magento 2's plugin system (also called interceptors) is the primary mechanism for extending core and third-party functionality without modifying original code. Plugins enable you to run custom code before, after, or around any public method in Magento, making them essential for building upgrade-safe, modular extensions.
What Are Plugins?
Plugins are PHP classes that intercept method calls on public methods of non-final classes. When a method is intercepted, Magento's generated code routes the call through your plugin, allowing you to:
- Modify input arguments (Before plugin)
- Modify return values (After plugin)
- Completely replace method logic (Around plugin)
When to Use Plugins vs Alternatives
| Mechanism | Use Case | Example |
|---|---|---|
| Plugin | Modify method behavior, arguments, or return values | Change product price calculation |
| Observer | React to events without return value | Send email after order placed |
| Preference | Replace entire class (last resort) | Override core class with major changes |
Plugin Types: Before, After, Around
Magento provides three plugin types, each with distinct capabilities and use cases.
Before Plugin
Purpose: Modify method arguments before the original method executes.
Signature: public function before<MethodName>($subject, $argument1, ...)
Return: Array of modified arguments (or null to keep original)
Key Point
Returning null keeps original arguments. Returning [] would replace arguments with an empty array, which would break the method call.
Example: Add customer group discount to product price calculation
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin\Catalog\Model\Product;
use Magento\Catalog\Model\Product;
use Magento\Customer\Model\Session as CustomerSession;
use Psr\Log\LoggerInterface;
class PriceExtend
{
public function __construct(
private readonly CustomerSession $customerSession,
private readonly LoggerInterface $logger
) {
}
public function beforeGetFinalPrice(Product $subject, $qty = 1.0): ?array
{
$customerGroupId = $this->customerSession->getCustomerGroupId();
// Example: Apply bulk discount for wholesale customer group (ID 3)
if ($customerGroupId === 3 && $qty < 10) {
$this->logger->info('Adjusting quantity to 10 for wholesale pricing');
return [10.0]; // Force minimum quantity for wholesale price tier
}
return null; // Return null to keep original arguments unchanged
}
}
After Plugin
Purpose: Modify the return value after the original method executes.
Signature: public function after<MethodName>($subject, $result, $argument1, ...)
Return: Modified result (must match original return type)
Example: Add custom attribute to product collection
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin\Catalog\Model\ResourceModel\Product;
use Magento\Catalog\Model\ResourceModel\Product\Collection;
use Psr\Log\LoggerInterface;
class CollectionExtend
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
public function afterLoad(Collection $subject, Collection $result): Collection
{
if ($result->count() === 0) {
return $result;
}
foreach ($result->getItems() as $product) {
$customValue = $this->calculateCustomValue($product);
$product->setData('custom_attribute', $customValue);
}
$this->logger->debug('Added custom attribute to product collection', [
'product_count' => $result->count()
]);
return $result;
}
private function calculateCustomValue($product): string
{
return 'custom_' . $product->getId();
}
}
Around Plugin
Purpose: Completely control method execution (call original, skip it, or replace it).
Signature: public function around<MethodName>($subject, callable $proceed, $argument1, ...)
Use Case: Conditional execution, caching, performance optimization
Warning
Around plugins are the most powerful but also most dangerous. Always call $proceed() unless intentionally skipping the original method.
Example: Add caching layer to expensive product recommendation
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin\Catalog\Model\Product;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;
class RecommendationExtend
{
private const CACHE_KEY_PREFIX = 'product_recommendations_';
private const CACHE_LIFETIME = 3600;
private const CACHE_TAG = 'product_recommendations';
public function __construct(
private readonly CacheInterface $cache,
private readonly SerializerInterface $serializer
) {
}
public function aroundGetRecommendations(
$subject,
callable $proceed,
$product,
int $limit = 5
): array {
$cacheKey = self::CACHE_KEY_PREFIX . $product->getId() . '_' . $limit;
// Try cache first
$cachedData = $this->cache->load($cacheKey);
if ($cachedData !== false) {
return $this->serializer->unserialize($cachedData);
}
// Cache miss - call original method
$result = $proceed($product, $limit);
// Store in cache
$this->cache->save(
$this->serializer->serialize($result),
$cacheKey,
[self::CACHE_TAG],
self::CACHE_LIFETIME
);
return $result;
}
}
Plugin Registration (di.xml)
Plugins are declared in etc/di.xml (global scope) or etc/frontend/di.xml / etc/adminhtml/di.xml (area-specific).
Basic Plugin Registration
<?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">
<plugin name="vendor_module_product_price_plugin"
type="Vendor\Module\Plugin\Catalog\Model\Product\PriceExtend"
sortOrder="10"
disabled="false" />
</type>
</config>
Plugin Attributes
| Attribute | Required | Description | Example |
|---|---|---|---|
| name | Yes | Unique plugin identifier | vendor_module_product_price_plugin |
| type | Yes | Fully qualified plugin class name | Vendor\Module\Plugin\Class |
| sortOrder | No | Execution order (default: 10) | 10 |
| disabled | No | Enable/disable plugin (default: false) | true |
Plugin Execution Order
When multiple plugins intercept the same method, execution order is determined by sortOrder (ascending).
Example: Three Plugins with Different sortOrder
<type name="ClassName">
<plugin name="pluginA" type="PluginA" sortOrder="10" />
<plugin name="pluginB" type="PluginB" sortOrder="20" />
<plugin name="pluginC" type="PluginC" sortOrder="30" />
</type>
Execution Sequence
- PluginA::beforeMethodName() (sortOrder 10)
- PluginB::beforeMethodName() (sortOrder 20)
- PluginC::beforeMethodName() (sortOrder 30)
- PluginA::aroundMethodName() (sortOrder 10, calls $proceed)
- PluginB::aroundMethodName() (sortOrder 20, calls $proceed)
- PluginC::aroundMethodName() (sortOrder 30, calls $proceed)
- Original method executes
- PluginC::aroundMethodName() returns (reverse order)
- PluginB::aroundMethodName() returns
- PluginA::aroundMethodName() returns
- PluginA::afterMethodName() (sortOrder 10)
- PluginB::afterMethodName() (sortOrder 20)
- PluginC::afterMethodName() (sortOrder 30)
Strategic sortOrder Values
| sortOrder | Purpose | Example |
|---|---|---|
| 1-9 | Early validation, security checks | Authentication plugin |
| 10-50 | Standard business logic | Price calculation |
| 51-90 | Post-processing, logging | Audit trail |
| 91-100 | Final transformations | Output formatting |
Common Plugin Mistakes
Mistake 1: Wrong Return Type in After Plugin
❌ Wrong
public function afterGetName(
Product $subject,
string $result
): array {
return ['modified_name'];
}
✅ Correct
public function afterGetName(
Product $subject,
string $result
): string {
return 'Modified: ' . $result;
}
Mistake 2: Not Calling $proceed in Around Plugin
❌ Wrong
public function aroundSave(
$subject,
callable $proceed,
$product
): ProductInterface {
if (!$this->isValid($product)) {
throw new \Exception('Invalid');
}
// Forgot $proceed - original never runs!
return $product;
}
✅ Correct
public function aroundSave(
$subject,
callable $proceed,
$product
): ProductInterface {
if (!$this->isValid($product)) {
throw new \Exception('Invalid');
}
return $proceed($product);
}
Performance Considerations
Optimization Tip
Prefer Before/After plugins over Around plugins when possible. Around plugins have 2-5x higher overhead due to wrapping the original method.
Plugin Performance Impact
| Type | Overhead | Reason |
|---|---|---|
| Before | Low | Simple argument pass-through |
| After | Low | Simple result pass-through |
| Around | High | Wraps original method, adds call stack depth |
Optimization Strategies
1. Minimize Around Plugins
❌ Avoid
public function aroundGetPrice(
$subject,
callable $proceed
): float {
$price = $proceed();
return $price * 1.1;
}
✅ Better
public function afterGetPrice(
$subject,
float $result
): float {
return $result * 1.1;
}
2. Use Area-Specific Plugins
Limit plugin scope to frontend or adminhtml to reduce overhead:
<!-- etc/frontend/di.xml -->
<type name="Magento\Catalog\Model\Product">
<plugin name="frontend_specific_plugin" type="FrontendPlugin" />
</type>
Production-Ready Plugin Example
Complete Example Following All Best Practices
- Type declarations with PHP 8.2+ strict types
- Constructor DI with readonly properties
- Exception handling with meaningful messages
- Logging for troubleshooting
- PHPDoc with @see, @param, @return, @throws
- Explicit sortOrder for predictable execution
Plugin/Sales/Api/OrderRepositoryExtend.php
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin\Sales\Api;
use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Psr\Log\LoggerInterface;
/**
* Plugin to add audit trail when orders are saved
*
* @see OrderRepositoryInterface
*/
class OrderRepositoryExtend
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly \Magento\Framework\Stdlib\DateTime\DateTime $dateTime,
private readonly \Magento\Backend\Model\Auth\Session $authSession
) {
}
/**
* Validate order before save
*/
public function beforeSave(
OrderRepositoryInterface $subject,
OrderInterface $order
): ?array {
if ($order->getGrandTotal() < 0) {
throw new LocalizedException(
__('Order grand total cannot be negative.')
);
}
$this->logger->debug('Order validation passed', [
'order_id' => $order->getEntityId(),
'grand_total' => $order->getGrandTotal()
]);
return null;
}
/**
* Add audit trail after order is saved
*/
public function afterSave(
OrderRepositoryInterface $subject,
OrderInterface $result,
OrderInterface $order
): OrderInterface {
$adminUser = $this->authSession->getUser();
$adminUsername = $adminUser ? $adminUser->getUserName() : 'system';
$this->logger->info('Order saved', [
'order_id' => $result->getEntityId(),
'admin_user' => $adminUsername,
'timestamp' => $this->dateTime->gmtDate(),
'status' => $result->getStatus()
]);
$result->setData('last_modified_by', $adminUsername);
$result->setData('last_modified_at', $this->dateTime->gmtDate());
return $result;
}
}
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\Sales\Api\OrderRepositoryInterface">
<plugin name="vendor_module_order_audit_plugin"
type="Vendor\Module\Plugin\Sales\Api\OrderRepositoryExtend"
sortOrder="100"
disabled="false" />
</type>
</config>
Debugging Plugins
Check Plugin Registration
# List all plugins for a class
bin/magento dev:di:info "Magento\Catalog\Model\Product"
Inspect Generated Interceptor
# View generated plugin wrapper
cat generated/code/Magento/Catalog/Model/Product/Interceptor.php
Enable Debug Logging
$this->logger->debug('Plugin executed', [
'method' => __METHOD__,
'subject_class' => get_class($subject),
'arguments' => func_get_args()
]);
Testing Plugins
Unit Test Example
<?php
namespace Vendor\Module\Test\Unit\Plugin;
use PHPUnit\Framework\TestCase;
use Vendor\Module\Plugin\MyPluginExtend;
class MyPluginExtendTest extends TestCase
{
private MyPluginExtend $plugin;
protected function setUp(): void
{
$this->plugin = new MyPluginExtend(
$this->createMock(\Psr\Log\LoggerInterface::class)
);
}
public function testAfterGetName(): void
{
$subjectMock = $this->createMock(
\Magento\Catalog\Model\Product::class
);
$originalResult = 'Product Name';
$result = $this->plugin->afterGetName(
$subjectMock,
$originalResult
);
$this->assertStringContainsString('Modified', $result);
}
}