Plugin System Deep Dive: Mastering Magento 2 Interception
Complete guide to Magento 2's plugin system (interceptors): Before, After, Around plugins, execution order, best practices, performance optimization, and production-ready examples
Plugin System Deep Dive: Mastering Magento 2 Interception
Learning Objectives
By completing this tutorial, you will:
- Understand the plugin (interceptor) pattern and how Magento implements it
- Master Before, After, and Around plugin types with real-world examples
- Control plugin execution order using
sortOrderand 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
- Implement production-ready plugins following SOLID principles
- Debug plugin chains and diagnose interception issues
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)
Why Use Plugins?
Advantages: - Upgrade-safe: No direct modification of core/third-party code - Modular: Multiple plugins can intercept the same method - Flexible: Choose Before/After/Around based on requirements - Testable: Plugins are DI-injected classes with explicit dependencies
Limitations: - Only work on public methods of non-final classes - Cannot intercept: final methods, static methods, constructors, private/protected methods - Performance cost: Generated code overhead (mitigated by compiled mode)
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 |
| Event Dispatch | Notify other modules of state changes | Dispatch custom event after import |
| Service Contract | Define new API contract | Create new repository interface |
Rule of thumb: Prefer plugins for method interception, observers for event-driven logic, and avoid preferences.
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>(<Type> $subject, <argument1>, <argument2>, ...)
Return: Array of modified arguments (or empty array to keep original)
Use Case: Validation, argument transformation, logging input
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
) {
}
/**
* Modify finalPrice calculation arguments based on customer group
*
* @param Product $subject The product instance being intercepted
* @param float $qty Quantity (original argument)
* @return array|null Modified arguments [qty] or null to keep original
*/
public function beforeGetFinalPrice(Product $subject, $qty = 1.0): ?array
{
$customerGroupId = $this->customerSession->getCustomerGroupId();
// Log for debugging
$this->logger->debug('Before getFinalPrice', [
'product_id' => $subject->getId(),
'original_qty' => $qty,
'customer_group' => $customerGroupId
]);
// 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
}
}
Key Points:
- Method name: before + PascalCase original method name
- First parameter: $subject (the intercepted object instance)
- Subsequent parameters: Match original method signature exactly
- Return: Array of modified arguments in same order as original method, or null to keep original
- Important: Returning null keeps original arguments. Returning [] would replace arguments with an empty array, which would break the method call. The interceptor checks if ($beforeResult !== null) before replacing arguments.
After Plugin
Purpose: Modify the return value after the original method executes.
Signature: public function after<MethodName>(<Type> $subject, <result>, <argument1>, <argument2>, ...)
Return: Modified result (must match original return type)
Use Case: Transform output, add data, logging result
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 Magento\Framework\DB\Select;
use Psr\Log\LoggerInterface;
class CollectionExtend
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
/**
* Add custom attribute to product collection after load
*
* @param Collection $subject The collection instance
* @param Collection $result The collection after _afterLoad (method returns $this)
* @return Collection Modified collection
*/
public function afterLoad(Collection $subject, Collection $result): Collection
{
// Only process if collection has items
if ($result->count() === 0) {
return $result;
}
// Add custom data to each product
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;
}
/**
* Example business logic
*/
private function calculateCustomValue($product): string
{
// Complex calculation based on product data
return 'custom_' . $product->getId();
}
}
Key Points:
- Method name: after + PascalCase original method name
- First parameter: $subject (the intercepted object)
- Second parameter: $result (return value from original method)
- Subsequent parameters: Original method arguments (for reference only)
- Must return value compatible with original method's return type
- Cannot access $subject state changed by original method (use $result)
Around Plugin
Purpose: Completely control method execution (call original, skip it, or replace it).
Signature: public function around<MethodName>(<Type> $subject, callable $proceed, <argument1>, <argument2>, ...)
Return: Modified result (must match original return type)
Use Case: Conditional execution, caching, performance optimization, complete behavior replacement
Example: Add caching layer to expensive product recommendation
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin\Catalog\Model\Product;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Model\Product;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Serialize\SerializerInterface;
use Psr\Log\LoggerInterface;
class RecommendationExtend
{
private const CACHE_KEY_PREFIX = 'product_recommendations_';
private const CACHE_LIFETIME = 3600; // 1 hour
private const CACHE_TAG = 'product_recommendations';
public function __construct(
private readonly CacheInterface $cache,
private readonly SerializerInterface $serializer,
private readonly LoggerInterface $logger
) {
}
/**
* Add caching layer around expensive getRecommendations call
*
* @param Recommendation $subject
* @param callable $proceed Original method callable
* @param ProductInterface $product
* @param int $limit
* @return ProductInterface[] Array of recommended products
*/
public function aroundGetRecommendations(
Recommendation $subject,
callable $proceed,
ProductInterface $product,
int $limit = 5
): array {
$cacheKey = $this->getCacheKey($product->getId(), $limit);
// Try cache first
$cachedData = $this->cache->load($cacheKey);
if ($cachedData !== false) {
$this->logger->debug('Cache hit for product recommendations', [
'product_id' => $product->getId(),
'limit' => $limit
]);
try {
return $this->serializer->unserialize($cachedData);
} catch (\InvalidArgumentException $e) {
$this->logger->error('Failed to unserialize cached recommendations', [
'error' => $e->getMessage()
]);
// Fall through to original method
}
}
// Cache miss - call original method
$this->logger->info('Cache miss, executing expensive recommendation logic', [
'product_id' => $product->getId()
]);
$result = $proceed($product, $limit); // Call original method
// Store in cache
try {
$this->cache->save(
$this->serializer->serialize($result),
$cacheKey,
[self::CACHE_TAG],
self::CACHE_LIFETIME
);
} catch (\InvalidArgumentException $e) {
$this->logger->error('Failed to cache recommendations', [
'error' => $e->getMessage()
]);
}
return $result;
}
/**
* Generate unique cache key
*/
private function getCacheKey(int $productId, int $limit): string
{
return self::CACHE_KEY_PREFIX . $productId . '_' . $limit;
}
}
Key Points:
- Method name: around + PascalCase original method name
- Second parameter: callable $proceed (invokes original method + subsequent plugins)
- Must call $proceed(...) to execute original method (unless intentionally skipping)
- Arguments passed to $proceed() can be modified
- Most powerful but also most dangerous (can break plugin chain if misused)
- Higher performance cost than Before/After
Common Around Plugin Mistake:
// WRONG: Not calling $proceed breaks plugin chain
public function aroundSomeMethod($subject, callable $proceed, $arg)
{
return 'my value'; // Original method and other plugins never execute!
}
// CORRECT: Always call $proceed unless you have specific reason
public function aroundSomeMethod($subject, callable $proceed, $arg)
{
$result = $proceed($arg); // Execute original + other plugins
return $this->modifyResult($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">
<!-- Plugin for product price calculation -->
<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>
Attributes:
| Attribute | Required | Description | Example |
|---|---|---|---|
name |
Yes | Unique plugin identifier (vendor_module_scope_plugin) | vendor_module_product_price_plugin |
type |
Yes | Fully qualified plugin class name | Vendor\Module\Plugin\Class |
sortOrder |
No | Execution order (default: 10) | 10 (lower = earlier) |
disabled |
No | Enable/disable plugin (default: false) | true to disable |
Plugin Naming Convention
Follow this pattern for plugin name attribute:
{vendor}_{module}_{scope}_{functionality}_plugin
Examples:
- acme_catalog_product_price_plugin
- acme_checkout_cart_validation_plugin
- acme_customer_authentication_plugin
Area-Specific Plugins
Limit plugin scope to frontend or adminhtml:
Frontend only (etc/frontend/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">
<!-- Only active on storefront -->
<type name="Magento\Checkout\Model\Cart">
<plugin name="vendor_module_cart_discount_plugin"
type="Vendor\Module\Plugin\Checkout\Model\Cart\DiscountExtend"
sortOrder="20" />
</type>
</config>
Admin only (etc/adminhtml/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">
<!-- Only active in admin panel -->
<type name="Magento\Sales\Api\OrderRepositoryInterface">
<plugin name="vendor_module_order_audit_plugin"
type="Vendor\Module\Plugin\Sales\Api\OrderRepositoryExtend"
sortOrder="30" />
</type>
</config>
Global (etc/di.xml): Active in all areas (frontend, adminhtml, crontab, webapi_rest, webapi_soap).
Plugin Execution Order
When multiple plugins intercept the same method, execution order is determined by sortOrder (ascending).
Execution Flow
Given three plugins on ClassName::methodName():
<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 when methodName() is called:
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 (sortOrder 30, reverse order)PluginB::aroundMethodName()returns (sortOrder 20)PluginA::aroundMethodName()returns (sortOrder 10)PluginA::afterMethodName()(sortOrder 10)PluginB::afterMethodName()(sortOrder 20)PluginC::afterMethodName()(sortOrder 30)
Key Observations:
- Before plugins: Execute in sortOrder (10 → 20 → 30)
- Around plugins: Execute in sortOrder on entry, reverse on exit (like nested function calls)
- After plugins: Execute in sortOrder (10 → 20 → 30), same order as before plugins
- Original method: Executes after all Around plugins call
$proceed()
Controlling Execution Order
Default sortOrder: If not specified, defaults to 10.
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 |
Example: Ensure validation runs before business logic:
<type name="Magento\Checkout\Model\Cart">
<!-- Validation runs first -->
<plugin name="vendor_module_cart_validation"
type="Vendor\Module\Plugin\Checkout\Validation"
sortOrder="5" />
<!-- Business logic runs second -->
<plugin name="vendor_module_cart_discount"
type="Vendor\Module\Plugin\Checkout\Discount"
sortOrder="20" />
<!-- Logging runs last -->
<plugin name="vendor_module_cart_logger"
type="Vendor\Module\Plugin\Checkout\Logger"
sortOrder="90" />
</type>
Debugging Plugin Order
View compiled plugin chain:
# Inspect generated interceptor
cat generated/code/Magento/Catalog/Model/Product/Interceptor.php
The Interceptor.php file shows the exact plugin sequence Magento will execute.
When to Use Plugins vs Observers vs Preferences
Decision Matrix
| Requirement | Solution | Reason |
|---|---|---|
| Modify method arguments | Before Plugin | Direct access to arguments |
| Modify return value | After Plugin | Direct access to result |
| Replace method entirely | Around Plugin (or Preference) | Can skip original method |
| React to event (no return value) | Observer | Event-driven, decoupled |
| Add data to object | After Plugin or Extension Attributes | Depends on persistence need |
| Validate input | Before Plugin or Validator | Plugins for interception, Validators for reusable logic |
| Replace entire class | Preference (last resort) | Only if plugins insufficient |
Plugin Use Cases
Best suited for:
- Price calculations: Modify product price based on customer group
- Inventory checks: Add custom stock validation
- URL generation: Customize product URLs
- API responses: Transform REST/GraphQL output
- Data enrichment: Add calculated fields to collections
- Caching: Wrap expensive methods with cache layer
- Logging: Track method calls for debugging
Example: Plugin for API Response Transformation
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin\Catalog\Api;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\SearchResultsInterface;
class ProductRepositoryExtend
{
/**
* Add computed field to product data after getById
*
* @param ProductRepositoryInterface $subject
* @param ProductInterface $result
* @param int $productId
* @return ProductInterface
*/
public function afterGetById(
ProductRepositoryInterface $subject,
ProductInterface $result,
int $productId
): ProductInterface {
// Add custom attribute to API response
$result->setCustomAttribute('api_version', '2.0');
$result->setCustomAttribute('response_time', microtime(true));
return $result;
}
/**
* Add computed fields to product list after getList
*
* @param ProductRepositoryInterface $subject
* @param SearchResultsInterface $result
* @return SearchResultsInterface
*/
public function afterGetList(
ProductRepositoryInterface $subject,
SearchResultsInterface $result
): SearchResultsInterface {
foreach ($result->getItems() as $product) {
$product->setCustomAttribute('list_position', $this->calculatePosition($product));
}
return $result;
}
private function calculatePosition(ProductInterface $product): int
{
// Business logic
return (int) $product->getId();
}
}
Observer Use Cases
Best suited for:
- Email notifications: Send email after order placement
- Third-party integrations: Sync data to external system
- Logging: Record events for audit trail
- Cache invalidation: Clear cache after data changes
- Analytics: Track user behavior
Example: Observer for Order Notification
<?php
declare(strict_types=1);
namespace Vendor\Module\Observer\Sales;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Psr\Log\LoggerInterface;
class OrderPlacedNotification implements ObserverInterface
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
/**
* Execute when sales_order_place_after event dispatches
*
* @param Observer $observer
* @return void
*/
public function execute(Observer $observer): void
{
/** @var OrderInterface $order */
$order = $observer->getData('order');
$this->logger->info('Order placed', [
'order_id' => $order->getEntityId(),
'customer_email' => $order->getCustomerEmail(),
'grand_total' => $order->getGrandTotal()
]);
// Send notification to external system
// No return value needed
}
}
Registration (etc/events.xml):
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="sales_order_place_after">
<observer name="vendor_module_order_notification"
instance="Vendor\Module\Observer\Sales\OrderPlacedNotification" />
</event>
</config>
Preference Use Cases (Avoid When Possible)
Only use preferences when:
- You must override private/protected methods (plugins can't intercept these)
- You need to replace entire class behavior (multiple methods)
- The class is final (plugins don't work on final classes)
- You're fixing a core bug temporarily until patch available
Preference risks:
- BC breaks: Only one preference allowed per class (last one wins)
- Upgrade issues: Core changes conflict with your preference
- Conflicts: Other modules' preferences clash with yours
Example: Preference for final class (hypothetical):
<!-- Only if ClassName is final and you have no other choice -->
<preference for="Magento\Framework\SomeClass"
type="Vendor\Module\Model\SomeClassCustom" />
Better alternative: Request Magento core to make class non-final or add plugin points.
Common Plugin Mistakes
Mistake 1: Wrong Return Type in After Plugin
// WRONG: Changing return type
public function afterGetName(Product $subject, string $result): array
{
return ['modified_name']; // Original returns string, not array!
}
// CORRECT: Return same type
public function afterGetName(Product $subject, string $result): string
{
return 'Modified: ' . $result;
}
Mistake 2: Not Calling $proceed in Around Plugin
// WRONG: Breaking plugin chain
public function aroundSave(ProductRepository $subject, callable $proceed, ProductInterface $product): ProductInterface
{
// Validation logic
if (!$this->isValid($product)) {
throw new \InvalidArgumentException('Invalid product');
}
// Forgot to call $proceed - original save never happens!
return $product;
}
// CORRECT: Always call $proceed
public function aroundSave(ProductRepository $subject, callable $proceed, ProductInterface $product): ProductInterface
{
if (!$this->isValid($product)) {
throw new \InvalidArgumentException('Invalid product');
}
return $proceed($product); // Call original method
}
Mistake 3: Modifying $subject State in Before Plugin
// WRONG: Before plugins should not modify $subject state
public function beforeSave(Product $subject): ?array
{
$subject->setName('Modified'); // Side effect!
return null;
}
// CORRECT: Modify via arguments or use After/Around plugin
public function aroundSave(Product $subject, callable $proceed): Product
{
$subject->setName('Modified'); // OK in Around plugin
return $proceed();
}
Mistake 4: Plugin on Final/Private/Static Methods
// These will NOT work:
// - final public methods
// - private/protected methods
// - static methods
// - __construct() method
// Check if method is pluginable:
class SomeClass {
final public function finalMethod() {} // Cannot plugin
private function privateMethod() {} // Cannot plugin
public static function staticMethod() {} // Cannot plugin
public function __construct() {} // Cannot plugin
public function pluginableMethod() {} // CAN plugin
}
Mistake 5: Circular Dependencies
// WRONG: Plugin creates circular dependency
namespace Vendor\Module\Plugin;
class ProductExtend {
public function __construct(
private readonly \Magento\Catalog\Model\Product $product // Circular!
) {}
public function afterGetName(\Magento\Catalog\Model\Product $subject, string $result): string {
return $this->product->getSku(); // Infinite loop!
}
}
// CORRECT: Inject factory or avoid injecting plugged class
public function __construct(
private readonly \Magento\Catalog\Model\ProductFactory $productFactory
) {}
Performance Considerations
Plugin Performance Impact
Cost per plugin type:
| Type | Overhead | Reason |
|---|---|---|
| Before | Low | Simple argument pass-through |
| After | Low | Simple result pass-through |
| Around | High | Wraps original method, adds call stack depth |
General overhead:
- Each plugin adds ~0.01-0.1ms per call (production mode)
- Around plugins: 2-5x slower than Before/After
- Compiled interceptors mitigate cost (use production mode)
Optimization Strategies
1. Minimize Around Plugins
// AVOID: Around plugin for simple transformation
public function aroundGetPrice(Product $subject, callable $proceed): float
{
$price = $proceed();
return $price * 1.1; // 10% markup
}
// BETTER: Use After plugin
public function afterGetPrice(Product $subject, float $result): float
{
return $result * 1.1;
}
2. Avoid Heavy Operations in Plugins
// AVOID: Database query in plugin on frequently-called method
public function afterGetName(Product $subject, string $result): string
{
$customData = $this->resource->getConnection()->fetchOne(
"SELECT custom_field FROM custom_table WHERE product_id = ?",
[$subject->getId()]
); // Executes on EVERY getName() call!
return $result . ' - ' . $customData;
}
// BETTER: Cache result or load in collection
private array $cache = [];
public function afterGetName(Product $subject, string $result): string
{
$productId = $subject->getId();
if (!isset($this->cache[$productId])) {
$this->cache[$productId] = $this->getCustomData($productId);
}
return $result . ' - ' . $this->cache[$productId];
}
3. Use Area-Specific Plugins
// AVOID: Global plugin active everywhere
<!-- etc/di.xml -->
<type name="Magento\Catalog\Model\Product">
<plugin name="frontend_specific_plugin" type="FrontendPlugin" />
</type>
// BETTER: Frontend-only plugin
<!-- etc/frontend/di.xml -->
<type name="Magento\Catalog\Model\Product">
<plugin name="frontend_specific_plugin" type="FrontendPlugin" />
</type>
4. Conditional Logic Early in Plugin
// AVOID: Processing every call
public function afterLoad(Collection $subject, Collection $result): Collection
{
foreach ($result->getItems() as $item) {
// Expensive operation on every item
$this->processItem($item);
}
return $result;
}
// BETTER: Early exit if not applicable
public function afterLoad(Collection $subject, Collection $result): Collection
{
// Exit early if wrong area or empty collection
if ($this->state->getAreaCode() !== Area::AREA_FRONTEND || $result->count() === 0) {
return $result;
}
foreach ($result->getItems() as $item) {
$this->processItem($item);
}
return $result;
}
Generated Code Management
Plugins generate Interceptor.php files in generated/code/. Keep generated code clean:
# Clear generated code after di.xml changes
bin/magento setup:di:compile
# Verify plugin is registered
grep -r "YourPluginClass" generated/metadata/
Production-Ready Plugin Example
Complete example following all best practices:
Module structure:
Vendor/Module/
├── Plugin/
│ └── Sales/
│ └── Api/
│ └── OrderRepositoryExtend.php
├── etc/
│ └── di.xml
├── registration.php
└── module.xml
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
{
/**
* @param LoggerInterface $logger
* @param \Magento\Framework\Stdlib\DateTime\DateTime $dateTime
* @param \Magento\Backend\Model\Auth\Session $authSession
*/
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
*
* @param OrderRepositoryInterface $subject
* @param OrderInterface $order
* @return array
* @throws LocalizedException
*/
public function beforeSave(
OrderRepositoryInterface $subject,
OrderInterface $order
): array {
// Validation logic
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 to keep original arguments unchanged
return null;
}
/**
* Add audit trail after order is saved
*
* @param OrderRepositoryInterface $subject
* @param OrderInterface $result Saved order
* @param OrderInterface $order Original order argument
* @return OrderInterface
*/
public function afterSave(
OrderRepositoryInterface $subject,
OrderInterface $result,
OrderInterface $order
): OrderInterface {
// Get admin user who made the change
$adminUser = $this->authSession->getUser();
$adminUsername = $adminUser ? $adminUser->getUserName() : 'system';
// Log audit trail
$this->logger->info('Order saved', [
'order_id' => $result->getEntityId(),
'admin_user' => $adminUsername,
'timestamp' => $this->dateTime->gmtDate(),
'status' => $result->getStatus(),
'state' => $result->getState()
]);
// Add audit data as custom attribute (optional)
$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>
Why this example is production-ready:
- Type declarations: All parameters and return types explicitly declared (PHP 8.2+ strict types)
- Constructor DI: Dependencies injected, testable
- Readonly properties: Immutable dependencies (PHP 8.1+)
- Exception handling: Validation with meaningful exception messages
- Logging: Debug and info logs for troubleshooting
- Documentation: PHPDoc with @see, @param, @return, @throws
- Naming: Clear plugin name following convention
- sortOrder: Explicit sortOrder for predictable execution
- Service contract: Plugs interface, not implementation (upgrade-safe)
- No side effects: Before plugin doesn't modify $subject
Debugging Plugins
Check Plugin Registration
# List all plugins for a class
bin/magento dev:di:info "Magento\Catalog\Model\Product"
# Output shows all configured plugins
Inspect Generated Interceptor
# View generated plugin wrapper
cat generated/code/Magento/Catalog/Model/Product/Interceptor.php
# Shows exact plugin execution order
Enable Debug Logging
// In plugin method
$this->logger->debug('Plugin executed', [
'method' => __METHOD__,
'subject_class' => get_class($subject),
'arguments' => func_get_args()
]);
Test Plugin in Isolation
// Unit test for plugin
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);
}
}
Testing Plugins
Unit Test Example
<?php
declare(strict_types=1);
namespace Vendor\Module\Test\Unit\Plugin\Catalog\Model\Product;
use Magento\Catalog\Model\Product;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Vendor\Module\Plugin\Catalog\Model\Product\PriceExtend;
class PriceExtendTest extends TestCase
{
private PriceExtend $plugin;
private LoggerInterface $loggerMock;
private Product $productMock;
protected function setUp(): void
{
$this->loggerMock = $this->createMock(LoggerInterface::class);
$this->productMock = $this->createMock(Product::class);
$this->plugin = new PriceExtend($this->loggerMock);
}
public function testAfterGetPriceAppliesMarkup(): void
{
$originalPrice = 100.0;
$expectedPrice = 110.0; // 10% markup
$result = $this->plugin->afterGetPrice($this->productMock, $originalPrice);
$this->assertEquals($expectedPrice, $result);
}
public function testAfterGetPriceLogsDebugInfo(): void
{
$this->productMock->method('getId')->willReturn(123);
$this->loggerMock->expects($this->once())
->method('debug')
->with(
$this->stringContains('Price modified'),
$this->arrayHasKey('product_id')
);
$this->plugin->afterGetPrice($this->productMock, 100.0);
}
}
Integration Test Example
<?php
declare(strict_types=1);
namespace Vendor\Module\Test\Integration\Plugin;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
class PricePluginIntegrationTest extends TestCase
{
private ProductRepositoryInterface $productRepository;
protected function setUp(): void
{
$this->productRepository = Bootstrap::getObjectManager()
->get(ProductRepositoryInterface::class);
}
/**
* @magentoDataFixture Magento/Catalog/_files/product_simple.php
* @magentoAppArea frontend
*/
public function testPluginModifiesProductPrice(): void
{
$product = $this->productRepository->get('simple');
$originalPrice = 10.0;
$this->assertEquals($originalPrice, $product->getPrice());
// After plugin should apply 10% markup
$finalPrice = $product->getFinalPrice();
$this->assertEquals(11.0, $finalPrice);
}
}
Related Resources
Official Documentation
- Magento DevDocs: Plugins (Interceptors)
- Magento DevDocs: Dependency Injection
- Magento DevDocs: Service Contracts
Community Resources
- Magento Stack Exchange: Plugin Questions
- Alan Storm: Magento 2 Plugin System
- Mage2.tv: Understanding Plugins
Further Learning
- Design Patterns: Decorator Pattern
- Design Patterns: Chain of Responsibility
- Magento Coding Standards (PHPCS)
Assumptions
- Target: Adobe Commerce / Magento Open Source 2.4.7
- PHP: 8.2+
- Environment: Development and production
- Scope: Backend (PHP modules, DI, plugins)
- Extensions: Custom modules in app/code or Composer packages
Why This Approach
- Plugins over Preferences: Upgrade-safe; multiple plugins can coexist
- Service Contracts: Plugin interfaces, not implementations, for BC
- Before/After over Around: Lower performance cost when possible
- Type Declarations: PHP 8.2+ strict types for reliability
- Constructor DI: Explicit dependencies, testable
- Logging: Debug and info logs for production troubleshooting
- Early Exit: Conditional logic at start of method reduces overhead
Alternatives considered: - Observers: No return value; can't modify method behavior - Preferences: Only one allowed; BC risk; conflicts common - Event Dispatch: Custom events for decoupling; plugins for interception - Rewrites (Magento 1): Deprecated; plugins replace rewrites in Magento 2
Security Impact
Authentication/Authorization
- Plugins on admin controllers: Ensure ACL checks not bypassed
- Plugins on API endpoints: Verify token/OAuth validation occurs
- Example: Plugin on
OrderRepositoryInterface::save()must not skip ACL
CSRF/Form Keys
- Plugins on POST handlers: Ensure form key validation not skipped
- Do not remove CSRF protection in plugins
XSS Escaping
- Plugins modifying HTML output: Escape all user input
- Example:
afterGetName()returning HTML must useescapeHtml()
PII/GDPR
- Plugins logging customer data: Ensure PII not logged in plain text
- Anonymize logs:
$this->logger->info('Customer action', ['customer_id' => hash('sha256', $customerId)])
Secrets Management
- Never log API keys, passwords, tokens in plugins
- Use
LoggerInterfacewith log level awareness (debug vs info)
Performance Impact
Full Page Cache (FPC)
- Plugins on cacheable blocks: May break FPC
- ESI holes for dynamic content: Use Varnish ESI instead of plugins
- Cache invalidation: Plugins modifying cached data must invalidate cache tags
Database Load
- Avoid N+1 queries in plugins on collection load
- Use
afterLoadto batch-fetch data, not per-item queries
Core Web Vitals (CWV)
- Plugins on frontend rendering: Minimize processing time
- Use profiler to measure plugin overhead:
bin/magento dev:profiler:enable
Cacheability
- Plugins on
CacheInterface: Can wrap cache operations for logging/metrics - Ensure cache keys unique: Include all parameters affecting output
Backward Compatibility
API/DB Schema Changes
- Plugins on data interfaces: Ensure added attributes don't break serialization
- Use extension attributes for adding data to entities
Upgrade Path
- Magento 2.4.6 → 2.4.7: Plugins remain compatible if targeting service contracts
- Magento 2.4.7 → 2.5: Verify plugin methods still exist; check deprecation notices
Migration Notes
- Review Magento release notes for deprecated methods
- Test plugins against new minor versions before upgrading
- Use
@deprecatedannotations in your plugin methods if replacing with new approach
Tests to Add
Unit Tests
- Test each plugin method in isolation
- Mock
$subject, verify return types - Test edge cases (null values, empty collections, exceptions)
Integration Tests
- Test plugin in real Magento environment with DI
- Verify plugin execution order with multiple plugins
- Test area-specific plugins (frontend vs adminhtml)
Functional Tests (MFTF)
- Test end-to-end flows affected by plugins
- Example: Product add to cart with price plugin active
Performance Tests
- Measure plugin overhead with
bin/magento dev:profiler:enable - Compare before/after plugin activation
- Target: <5ms overhead per plugin in production mode
Documentation to Update
Code Comments
- PHPDoc for all plugin methods:
@param,@return,@throws,@see - Explain why plugin is needed, not just what it does
Module README
## Plugins
This module registers the following plugins:
- `Vendor\Module\Plugin\Catalog\Model\Product\PriceExtend`: Applies 10% markup to product prices for customer group 3 (Wholesale). Registered on `Magento\Catalog\Model\Product::getPrice()`.
sortOrder: 20 (runs after core price calculations)
CHANGELOG
## [1.0.0] - 2026-02-05
### Added
- Plugin on `ProductRepositoryInterface::save()` for order audit trail
- Plugin on `Product::getFinalPrice()` for dynamic pricing
Admin User Guide
- Document observable behavior changes caused by plugins
- Example: "Wholesale customers (group 3) automatically receive 10% discount on all products"
Related Documentation
Related Guides
- Service Contracts vs Repositories in Magento 2
- Comprehensive Testing Strategies for Magento 2
- Declarative Schema & Data Patches: Modern Database Management in Magento 2