Skip to content

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.

1

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
    }
}
2

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();
    }
}
3

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

  1. PluginA::beforeMethodName() (sortOrder 10)
  2. PluginB::beforeMethodName() (sortOrder 20)
  3. PluginC::beforeMethodName() (sortOrder 30)
  4. PluginA::aroundMethodName() (sortOrder 10, calls $proceed)
  5. PluginB::aroundMethodName() (sortOrder 20, calls $proceed)
  6. PluginC::aroundMethodName() (sortOrder 30, calls $proceed)
  7. Original method executes
  8. PluginC::aroundMethodName() returns (reverse order)
  9. PluginB::aroundMethodName() returns
  10. PluginA::aroundMethodName() returns
  11. PluginA::afterMethodName() (sortOrder 10)
  12. PluginB::afterMethodName() (sortOrder 20)
  13. 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);
    }
}