Plugins (Interceptors) vs. Event Observers in Magento 2

Plugins (Interceptors) vs. Event Observers in Magento 2

The difference between Magento plugins and event observers. Find out which design pattern you should choose depending upon your implementation.

When you're working with Magento, you'll quickly find there are several ways to customize and extend its functionality. Two of the most common tools at your disposal are plugins (also known as interceptors) and event observers.

But how do you know which one to implement for your specific use-case?

In this article, we'll break down what plugins and observers are, how they differ from each other, and then go over a couple real-life examples of each. By the end, I hope you’ll have a clearer understanding of when to use a plugin, or when an observer may be the better choice.

Understanding Plugins

Plugins allow you to modify the behavior of public methods in existing classes without changing the class's core code, like we usually do with class preferences. This means that you can extend or alter core code functionality in a safe, upgrade-friendly way.

Plugins are also called interceptors because of the way they "intercept" a method call and execute custom code before, after, or around it.

Magento plugins come in three types:

  1. Before Plugins
  2. After Plugins
  3. Around Plugins

While you may already know what each type does, let’s quickly recap them for as a refresh.

Before Plugins

Before plugins are useful when you need to modify the input parameters from the base function, or need to carry out an action before a method executes. An example of this is adding or updated data on a customer record just before it’s saved.

For example, we could override the default group ID based on an incoming email address:

Plugin/SetCustomerGroupBySpecialEmailDomain.php
<?php
 
declare(strict_types=1);
 
namespace Vendor\Module\Plugin;
 
use Magento\Customer\Api\CustomerRepositoryInterface;
use Magento\Customer\Api\Data\CustomerInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
 
class SetCustomerGroupBySpecialEmailDomain
{
    private const XML_PATH_SPECIAL_EMAIL_DOMAINS = 'customer/groups/special_email_domains';
    private const SPECIAL_GROUP_ID = 2;
 
    public function __construct(
        private ScopeConfigInterface $scopeConfig,
    ) {}
 
    public function beforeSave(
        CustomerRepositoryInterface $subject,
        CustomerInterface $customer,
        $passwordHash = null,
    ) {
        $email = $customer->getEmail();
        $specialDomains = $this->getSpecialEmailDomains();
        $specialGroupId = $this->getSpecialGroupId();
 
        foreach ($specialDomains as $domain) {
            if (str_ends_with($email, '@' . trim($domain))) {
                // 1 is the default customer group ID
                $customer->setGroupId(SPECIAL_GROUP_ID);
                break;
            }
        }
 
        return [$customer, $passwordHash];
    }
 
    private function getSpecialEmailDomains(): array
    {
        $domainsString = $this->scopeConfig->getValue(self::XML_PATH_SPECIAL_EMAIL_DOMAINS);
        return $domainsString ? explode(',', $domainsString) : [];
    }
}
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\Customer\Api\CustomerRepositoryInterface">
        <plugin name="vendor_module_set_customer_group_by_special_email_domain" type="Vendor\Module\Plugin\SetCustomerGroupBySpecialEmailDomain" />
    </type>
</config>

In this plugin, before the save method executes on the CustomerRepositoryInterface, we're checking that the customer’s email address domain matches against a comma-delimited config value. If it does, we will override the default group ID with a custom one. This could be useful if you have a list of customer domains that you’d like to always belong to a specific customer group.

After Plugins

After plugins are ideal when you need to modify the result returned by a method. They allow you to intercept and potentially alter the output of a function after it has been executed.

A practical example of this is modifying product prices after they've been loaded from the database. For example, we could implement a custom loyalty discount which applies a percentage discount to product prices, but only for logged-in customers:

Plugin/LoggedInCustomerLoyaltyDiscount.php
<?php
 
declare(strict_types=1);
 
namespace Vendor\Module\Plugin;
 
use Magento\Catalog\Model\Product;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Framework\App\Config\ScopeConfigInterface;
 
class LoggedInCustomerLoyaltyDiscount
{
    private const XML_PATH_LOYALTY_DISCOUNT_PERCENT = 'sales/loyalty/discount_percent';
 
    public function __construct(
        private CustomerSession $customerSession,
        private ScopeConfigInterface $scopeConfig,
    ) {}
 
    public function afterGetPrice(Product $subject, $result)
    {
        if ($this->customerSession->isLoggedIn()) {
            $discountPercent = $this->getLoyaltyDiscountPercent();
            $result = $result * (1 - $discountPercent / 100);
        }
 
        return $result;
    }
 
    private function getLoyaltyDiscountPercent(): float
    {
        return (float) $this->scopeConfig->getValue(self::XML_PATH_LOYALTY_DISCOUNT_PERCENT) ?: 0;
    }
}
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\Model\Product">
        <plugin name="vendor_module_logged_in_customer_loyalty_discount" type="Vendor\Module\Plugin\LoggedInCustomerLoyaltyDiscount" />
    </type>
</config>

In this plugin, after the getPrice method executes on the Product model, we're checking if the customer is logged in. If they are, we apply a loyalty discount to the product price. The discount percentage is retrieved from the system configuration, allowing store administrators to easily adjust the discount without modifying code.

Around Plugins

Around plugins give you the most control of the three types of plugins. They allow you to modify input parameters, change the flow of code execution, or completely skip the execution of the original method.

For example, you can write to a log every time a product is saved. It can execute before and after the save event, and do so within the same plugin.

Plugin/ProductSaveLogger.php
<?php
 
declare(strict_types=1);
 
namespace Vendor\Module\Plugin;
 
use Magento\Catalog\Model\Product;
use Psr\Log\LoggerInterface;
 
class ProductSaveLogger
{
    public function __construct(
        private LoggerInterface $logger,
    ) {}
 
    public function aroundSave(
        Product $subject,
        callable $proceed,
    ) {
        // We can use $subject to call a method on the original class
        $this->logger->info('Before product save: ' . $subject->getId());
 
        // Proceed with the original save method, storing the result
        $result = $proceed();
 
        $this->logger->info('After product save: ' . $subject->getId());
 
        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\Catalog\Model\Product">
        <plugin name="vendor_module_product_save_logger" type="Vendor\Module\Plugin\ProductSaveLogger" />
    </type>
</config>

In this around plugin, we're logging messages before and after the product save operation. The call to $proceed() will execute the original method, and return the response as $result. We can then carry out another action, before returning the response from the original method.

Note that while they are powerful, it is not recommend to use around plugins unless absolutely necessary, as they increase the size of the call stack and can affect performance, make debugging code much more difficult, and can help assist in the formation of “spaghetti code” by trying to do too much.

When to Use Plugins

Use plugins when you need to:

  • Modify or extend specific methods in Magento classes.
  • Alter method parameters or results without changing the core code.
  • Control the execution flow of a method, including skipping it entirely if necessary.

Plugins are tightly coupled to the methods they intercept. They're best used when your customization is directly related to a function of a specific class's behavior.

Understanding Event Observers

Event observers let you execute code in response to events that occur throughout Magento. This is part of the Observer Design Pattern, where observers listen and respond to events.

How Observers Work

When a certain event happens in Magento, it can be dispatched. When it is, any observers which are listening for that event are executed. Observers are great for adding functionality when it can be completely decoupled from the core code.

For example, let’s say you wanted to send an email out to the store admin whenever a new customer registers with a customer group of 2:

Observer/SendCustomerRegistrationEmail.php
<?php
 
declare(strict_types=1);
 
namespace Vendor\Module\Observer;
 
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Magento\Framework\Mail\Template\TransportBuilder;
use Magento\Framework\Translate\Inline\StateInterface;
use Magento\Store\Model\StoreManagerInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
 
class SendCustomerRegistrationEmail implements ObserverInterface
{
    private const XML_PATH_EMAIL_RECIPIENT = 'customer/new_customer_notification/recipient_email';
 
    public function __construct(
        private TransportBuilder $transportBuilder,
        private StateInterface $inlineTranslation,
        private StoreManagerInterface $storeManager,
        private ScopeConfigInterface $scopeConfig,
    ) {}
 
    public function execute(Observer $observer)
    {
        $customer = $observer->getEvent()->getCustomer();
 
        if ($customer && $customer->getGroupId() == 2) {
            $this->sendEmail($customer);
        }
    }
 
    private function sendEmail($customer)
    {
        $this->inlineTranslation->suspend();
 
        try {
            $storeId = $this->storeManager->getStore()->getId();
            $recipientEmail = $this->scopeConfig->getValue(self::XML_PATH_EMAIL_RECIPIENT, 'store', $storeId);
            $transport = $this->transportBuilder
                ->setTemplateIdentifier('new_customer_notification')
                ->setTemplateOptions(['area' => 'frontend', 'store' => $storeId])
                ->setTemplateVars([
                    'customer_name' => $customer->getName(),
                    'customer_email' => $customer->getEmail()
                ])
                ->setFrom(['email' => 'sender@example.com', 'name' => 'Sender Name'])
                ->addTo($recipientEmail)
                ->getTransport();
            $transport->sendMessage();
        } catch (\Exception $e) {
            // Log the error or handle it as needed
        } finally {
            $this->inlineTranslation->resume();
        }
    }
}
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="customer_register_success">
        <observer name="vendor_module_send_customer_registration_email" instance="Vendor\Module\Observer\SendCustomerRegistrationEmail" />
    </event>
</config>

In this observer, we're listening to the customer_register_success event. When a new customer registers with a customer group of 2, our observer will send an email to the specified admin email address. It's a great example of how observers can add functionality that's completely decoupled from the core registration process.

Why did we disable inline translation when sending emails? We do this because any translatable text would otherwise pass through the inline phrase renderer at Magento\Framework\Phrase\Renderer\Inline::render. If inline translation is enabled, you'd get text formatted {{ like this }} (with the brackets) and since there is no JavaScript in emails, the special markup wouldn't be interpreted.

When to Use Observers

Use observers when you need to:

  • React to system-wide events that aren't tied to a specific method.
  • Add functionality that's decoupled from the core codebase.
  • Perform actions across multiple areas of Magento when certain events occur.

Observers are less about altering how Magento works internally and more about responding to what Magento is doing.

Deciding Between Plugins and Observers

Choosing between a plugin and an observer depends on what you're trying to achieve. Here's a simplified guide to help you decide.

Use a Plugin When:

  • You need to modify the behavior of a specific method.
  • Your customization is directly related to a particular class or function.
  • You want to change method inputs or outputs.
  • You need fine-grained control over the method execution.

Use an Observer When:

  • You need to react to an event that can happen in various places.
  • Your functionality is not specific to one method or class.
  • You want to add additional processing when certain actions occur.
  • You aim to keep your code decoupled from Magento core classes.

When The Plugin/Event Doesn’t Work

Let's look at the previous scenario above when we set a customer group ID to 2 on save, depending upon their email address, and also sent out an email when a customer registers with a customer group ID of 2.

You'd think this would work, but there is actually a big hiccup with this code relating to the logic flow if we wanted both of these functionalities to work together.

The problem lies in the execution order and the timing of when these two pieces of code run. Our plugin modifies the customer group ID during the save process, while our observer is triggered on the customer registration success event. However, Magento doesn't guarantee that these will execute in the order we might expect.

Here's what could happen:

  1. A new customer registers with an email domain that should put them in group 2.
  2. The registration success event is fired, triggering our observer.
  3. At this point, the customer's group ID is still the default (likely 1), so our observer doesn't send the email.
  4. Our plugin then runs, changing the group ID to 2.
  5. The customer is now in group 2, but the email notification wasn't sent.

This misalignment between our intentions and the actual execution flow can lead to inconsistent behavior and missed notifications. It's a perfect example of why we need to be careful when using multiple plugins or observers, or when mixing these two approaches.

Addressing the Issue

To solve this problem, we have a few options:

  1. Use a Single Observer: We could combine both pieces of functionality into a single observer that runs on the customer registration event. This way, we can control the order of operations:

    public function execute(Observer $observer)
    {
        $customer = $observer->getEvent()->getCustomer();
        $email = $customer->getEmail();
     
        // First, check and update the group ID
        if ($this->shouldBeInSpecialGroup($email)) {
            $customer->setGroupId(SPECIAL_GROUP_ID);
            $customer->save();
        }
     
        // Then, check if we need to send the email
        if ($customer->getGroupId() === 2) {
            $this->sendEmail($customer);
        }
    }
  2. Use an After Plugin for Email Sending: Instead of using an observer for the email, we could use an after plugin on the customer save method. This ensures it runs after the group ID has been potentially modified:

    public function afterSave(
        \Magento\Customer\Api\CustomerRepositoryInterface $subject,
        \Magento\Customer\Api\Data\CustomerInterface $result,
    ) {
        if ($result->getGroupId() === SPECIAL_GROUP_ID) {
            $this->sendEmail($result);
        }
     
        return $result;
    }
  3. Dispatch a Custom Event: We could modify our group ID plugin to dispatch a custom event when it changes a customer's group to 2, and then observe that event for sending emails:

    // In the plugin
    if ($newGroupId === 2 && $customer->getGroupId() !== SPECIAL_GROUP_ID) {
        $this->eventManager->dispatch('customer_group_changed', ['customer' => $customer]);
    }
     
    // Then create an observer for this new event

Each of these approaches has its pros and cons, and the best choice depends on your specific use case and how it fits into the broader architecture of your Magento implementation. That said, since sending an email can be a blocking-event, dispatching a custom event will allow this action to be carried out asynchronously, so it is probably, (usually), the correct choice.

Simplifying the Decision Process

To make your choice of deciding whether to use a plugin or event observer easier, ask yourself these questions:

  1. Am I changing how a specific method works or do I need to intercept a method call to alter parameters or results?
    • Yes: Use a Plugin, but be aware of potential execution order issues and interactions with other plugins or observers.
    • No: Consider an Observer, or continue to the next question.
  2. Is my code tightly related to a class's internal behavior?
    • Yes: A Plugin is likely the best choice, but ensure it doesn't conflict with other customizations.
    • No: Consider an Observer for better decoupling, or continue to the next question.
  3. Do I need to react to something happening in the system or perform a global action based on an event?
    • Yes: Use an Observer, but consider the timing of when it's triggered and if you need guaranteed execution order.
    • No: A Plugin might be more appropriate, or continue to the next question.
  4. Am I unsure or dealing with a complex scenario involving multiple related tasks?
    • Yes: Consider consolidating into a single plugin or observer, or create a custom event for better control over execution order.
    • No: Revisit the previous questions or consult Magento documentation for edge cases.

Best Practices

Regardless of which tool you choose, keep these best practices in mind:

  • Keep It Simple: Don't overcomplicate your code. Write clear, concise plugins or observers that do one thing well.
  • Consider Execution Order: Be aware of how your plugins and observers interact with each other and with Magento's core functionality.
  • Avoid Conflicts: Be mindful of conflicts, especially with plugins. Multiple plugins on the same method can cause issues.
  • Performance Matters: Ensure your code doesn't negatively impact performance, especially with Observers which can be triggered multiple times.
  • Consolidate When Necessary: If you find yourself using multiple plugins or observers that need to work together, consider consolidating them into a single point of execution for better control.

Recap

Magento's flexibility is both a strength and a challenge. While it offers multiple ways to achieve your goals, it's crucial to understand how these different approaches interact within the system so you can write the most efficient code.

As you write your own modules and gain more experience, you'll naturally develop a better intuition for choosing the right design pattern for your code to best work with Magento’s framework. Keep experimenting, making mistakes, testing, and learning from your experiences so you can become more aware of the unexpected behaviors out there.

  1. Read more about dependency injection in Magento 2 (Free article)
  2. Grow your Magento expertise with all courses & lessons (700+ students)
  3. Learn visually with blocks of code & inline comments (3,000+ students)