Use a plugin when you need to change the input, output or execution of one public method on one class. Use an observer when core already dispatches an event at the moment you care about and you only need to react to it. Plugins can't touch private, protected or final methods; observers can't change a method's return value.
Plugins and observers both let you change Magento without touching core. The difference is what you hook into: a plugin wraps one method on one class, an observer reacts to an event that core decided to dispatch. Both sit in the middle of the Magento training path.
Below is a working example of each plugin type and an observer, then what happens when you combine them, which is where the real gotcha lives.
Understanding Plugins
Plugins allow you to modify the behavior of public methods in existing classes without replacing the whole class, which is what a preference does. 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:
- Before Plugins
- After Plugins
- Around Plugins
Quick recap of each.
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 updating 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:
<?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,
): array {
$email = strtolower((string) $customer->getEmail());
foreach ($this->getSpecialEmailDomains() as $domain) {
if (str_ends_with($email, '@' . $domain)) {
$customer->setGroupId(self::SPECIAL_GROUP_ID);
break;
}
}
return [$customer, $passwordHash];
}
private function getSpecialEmailDomains(): array
{
$domains = (string) $this->scopeConfig->getValue(self::XML_PATH_SPECIAL_EMAIL_DOMAINS);
return array_filter(array_map(
fn (string $domain): string => strtolower(trim($domain)),
explode(',', $domains)
));
}
}<?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 (a custom path you'd add in your module's system.xml). If it does, we 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.
The before method returns the full argument list as an array, and whatever it returns becomes the arguments the original save() receives.
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 tempting example is a loyalty discount: an afterGetPrice plugin on Magento\Catalog\Model\Product that knocks a percentage off for logged-in customers. Don't do that one. getPrice() is the raw price attribute that the admin, the price indexer, quote totals and price rendering all read, so you'd discount it everywhere (sometimes twice), and the customer session is empty while full-page-cached blocks render. Real customer pricing belongs in customer group prices or catalog price rules.
An after plugin is a much better fit for adding data to a result than for rewriting a value the whole system depends on. Say the discount itself comes from a catalog price rule, and the storefront wants to show logged-in customers a "you save 5% as a member" banner. We can add the percentage to the customer section data, which the storefront loads over AJAX, so it plays nicely with full-page cache:
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin;
use Magento\Customer\CustomerData\Customer;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
class AddLoyaltyDiscountToCustomerData
{
private const XML_PATH_LOYALTY_DISCOUNT_PERCENT = 'sales/loyalty/discount_percent';
public function __construct(
private ScopeConfigInterface $scopeConfig,
) {}
public function afterGetSectionData(Customer $subject, array $result): array
{
// Guests get an empty array back, so leave it alone
if ($result === []) {
return $result;
}
$result['loyalty_discount_percent'] = (float) $this->scopeConfig->getValue(
self::XML_PATH_LOYALTY_DISCOUNT_PERCENT,
ScopeInterface::SCOPE_STORE
);
return $result;
}
}<?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\CustomerData\Customer">
<plugin name="vendor_module_add_loyalty_discount_to_customer_data" type="Vendor\Module\Plugin\AddLoyaltyDiscountToCustomerData" />
</type>
</config>In this plugin, after getSectionData() executes, we add one key to the array it returns. Any template or JS component can then read customerData.get('customer')().loyalty_discount_percent. The discount percentage comes from system configuration, so store admins can adjust it without touching code.
Always return $result from an after plugin. Forget it, and the method's return value becomes null.
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.
That last one is the honest reason to reach for an around plugin. If all you need is code before and after a call (logging a save, for example), use a before plugin plus an after plugin instead.
Here's a real "skip the original" case: don't send Magento's welcome email to internal test accounts.
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin;
use Magento\Customer\Api\Data\CustomerInterface;
use Magento\Customer\Model\EmailNotificationInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
class SkipWelcomeEmailForTestAccounts
{
private const XML_PATH_TEST_EMAIL_DOMAINS = 'customer/welcome_email/test_email_domains';
public function __construct(
private ScopeConfigInterface $scopeConfig,
) {}
public function aroundNewAccount(
EmailNotificationInterface $subject,
callable $proceed,
CustomerInterface $customer,
$type = EmailNotificationInterface::NEW_ACCOUNT_EMAIL_REGISTERED,
$backUrl = '',
$storeId = 0,
$sendemailStoreId = null,
): void {
if ($this->isTestAccount((string) $customer->getEmail())) {
// Intentionally not calling $proceed(): test accounts get no welcome email
return;
}
$proceed($customer, $type, $backUrl, $storeId, $sendemailStoreId);
}
private function isTestAccount(string $email): bool
{
$domains = (string) $this->scopeConfig->getValue(self::XML_PATH_TEST_EMAIL_DOMAINS);
foreach (explode(',', $domains) as $domain) {
$domain = strtolower(trim($domain));
if ($domain !== '' && str_ends_with(strtolower($email), '@' . $domain)) {
return true;
}
}
return false;
}
}<?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\Model\EmailNotificationInterface">
<plugin name="vendor_module_skip_welcome_email_for_test_accounts" type="Vendor\Module\Plugin\SkipWelcomeEmailForTestAccounts" />
</type>
</config>The call to $proceed() executes the original method (and any plugins after this one). For a test account we simply never call it, and the comment says so, so the next developer doesn't think it's a bug.
Note that while they are powerful, around plugins are not recommended unless absolutely necessary. They increase the size of the call stack, make debugging code much more difficult, and make it easy to build "spaghetti code" by trying to do too much. Around plugins also nest every plugin after them inside $proceed(). Here's the exact order Magento runs them in.
What can't a plugin intercept?
This is the most common "why won't my plugin fire?" question. Per Adobe's plugin docs, plugins don't work on:
- Final methods and final classes
- Non-public methods (
protectedandprivate) - Static methods
__constructand__destruct- Virtual types (put the plugin on the real class instead)
- Objects instantiated before
Magento\Framework\Interceptionis bootstrapped - Classes that implement
Magento\Framework\ObjectManager\NoninterceptableInterface
And one that isn't on that list but bites people all the time: an object created with new never gets an interceptor, because it never went through the Object Manager. That's one more reason to get your objects through DI.
If you're not sure a plugin is registered, bin/magento dev:di:info lists every plugin Magento will apply to a class.
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:
<?php
declare(strict_types=1);
namespace Vendor\Module\Observer;
use Magento\Customer\Api\Data\CustomerInterface;
use Magento\Framework\App\Area;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Mail\Template\TransportBuilder;
use Magento\Framework\Translate\Inline\StateInterface;
use Magento\Store\Model\ScopeInterface;
use Magento\Store\Model\StoreManagerInterface;
use Psr\Log\LoggerInterface;
class SendCustomerRegistrationEmail implements ObserverInterface
{
private const XML_PATH_EMAIL_RECIPIENT = 'customer/new_customer_notification/recipient_email';
private const SPECIAL_GROUP_ID = 2;
public function __construct(
private TransportBuilder $transportBuilder,
private StateInterface $inlineTranslation,
private StoreManagerInterface $storeManager,
private ScopeConfigInterface $scopeConfig,
private LoggerInterface $logger,
) {}
public function execute(Observer $observer): void
{
$customer = $observer->getEvent()->getCustomer();
if ($customer instanceof CustomerInterface
&& (int) $customer->getGroupId() === self::SPECIAL_GROUP_ID
) {
$this->sendEmail($customer);
}
}
private function sendEmail(CustomerInterface $customer): void
{
$this->inlineTranslation->suspend();
try {
$storeId = (int) $this->storeManager->getStore()->getId();
$recipientEmail = $this->scopeConfig->getValue(
self::XML_PATH_EMAIL_RECIPIENT,
ScopeInterface::SCOPE_STORE,
$storeId
);
$transport = $this->transportBuilder
->setTemplateIdentifier('new_customer_notification')
->setTemplateOptions(['area' => Area::AREA_FRONTEND, 'store' => $storeId])
->setTemplateVars([
'customer_name' => trim($customer->getFirstname() . ' ' . $customer->getLastname()),
'customer_email' => $customer->getEmail(),
])
->setFromByScope('general', $storeId)
->addTo($recipientEmail)
->getTransport();
$transport->sendMessage();
} catch (\Exception $e) {
$this->logger->error('New customer notification failed: ' . $e->getMessage());
} finally {
$this->inlineTranslation->resume();
}
}
}<?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 sends an email to the configured admin address, using the general store email identity as the sender. The new_customer_notification template is your own, declared in your module's etc/email_templates.xml.
The customer in this event is a CustomerInterface data object, not the old customer model. That's why the name is built from getFirstname() and getLastname() (there's no getName()), and why the group ID is cast to int before the strict comparison.
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 and the observer don't line up
Let's put the before plugin and the observer together: the plugin sets group 2 on save based on the email domain, and the observer emails the admin when a group 2 customer registers.
You might expect a timing problem here. On the storefront, there isn't one. Here's the actual flow in Magento\Customer\Controller\Account\CreatePost::execute():
- The controller calls
AccountManagement::createAccount(). - That ends in
$this->customerRepository->save($customer, $hash), which is where our before plugin runs and sets group 2. - Only after the save returns does the controller dispatch
customer_register_success, passing the saved customer.
So by the time the observer runs, the group ID is already 2, and the email goes out. The order is fine. The entry point is the problem.
customer_register_success is dispatched in exactly one place in core: that storefront registration controller. A customer created in the admin, or through POST /V1/customers in the REST API, goes through createAccount() and the repository save (so our plugin still sets group 2), but the event never fires, so the email silently never sends. A customer import writes straight to the database and skips both.
The plugin has the opposite problem: it runs on every CustomerRepositoryInterface::save(), from every path. That includes an admin editing an existing customer, so a customer someone deliberately moved out of group 2 gets forced back into it the next time anyone saves them. If that's not what you want, check for a new customer (!$customer->getId()) before setting the group.
This is a perfect example of why you need to know where an event is actually dispatched before you depend on it. An event name like customer_register_success sounds global. It isn't.
Addressing the Issue
The fix is to hook into something every creation path goes through. CustomerRepository::save() dispatches customer_save_after_data_object on every save, from every entry point, with the saved customer and the customer as it was before the save. For a brand-new customer, that "before" object is null:
public function execute(Observer $observer): void
{
$customer = $observer->getEvent()->getData('customer_data_object');
$origCustomer = $observer->getEvent()->getData('orig_customer_data_object');
// Only brand-new customers have no "before" version
$isNew = $origCustomer === null;
if ($isNew
&& $customer instanceof CustomerInterface
&& (int) $customer->getGroupId() === self::SPECIAL_GROUP_ID
) {
$this->sendEmail($customer);
}
}<?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_save_after_data_object">
<observer name="vendor_module_send_customer_registration_email" instance="Vendor\Module\Observer\SendCustomerRegistrationEmail" />
</event>
</config>The sendEmail() method stays the same. Now storefront, admin and REST registrations all send the email, and the group ID is already set because our before plugin ran earlier in the same save() call.
One thing this doesn't change: the email is still sent during the request. Events are not async. EventManager::dispatch() runs every observer inline, in the same PHP process, before the code that dispatched it continues. Dispatching a custom event doesn't move work out of the request either. If a slow mail server shouldn't slow down registration, publish a message to a queue in the observer and send the email from a consumer. The message queues course covers how to set that up.
Simplifying the Decision Process
To make your choice of deciding whether to use a plugin or event observer easier, ask yourself these questions:
- 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.
- No: Consider an Observer, or continue to the next question.
- 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.
- 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 check every place the event is dispatched, and whether every path you care about dispatches it.
- No: A Plugin might be more appropriate, or continue to the next question.
- Am I unsure or dealing with a complex scenario involving multiple related tasks?
- Yes: Consider consolidating into a single plugin or observer, hooked into a point every code path goes through.
- No: Revisit the previous questions, or grep core for the method or event name and read the code around it.
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 on events that fire many times per request.
- 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. It offers multiple ways to hook into the same behavior, so it pays to know exactly where each hook fires before you pick one.
Plugins change what a method does. Observers react to what Magento already announced. When in doubt, read the core code that calls the method or dispatches the event.
If you're stacking more than one plugin on the same method, the next thing to read is how Magento orders plugin execution, especially once an around plugin gets involved.



