Magento 2 Plugins vs Observers vs Preferences: When to Use Each
Magento 2 gives you three main ways to change behaviour without editing core files: plugins (interceptors), event observers and preferences. All three work, and on most codebases I review all three are used, often for the wrong job. The choice matters because it decides how well your change survives upgrades and how well it plays with other extensions that touch the same code.
This article explains how each mechanism works in Magento 2.4.8, what it can and cannot do, and the order in which I reach for them.
The short answer
- Use an observer when Magento already dispatches an event at the point you care about and you only need to react to it.
- Use a plugin when you need to change the input or output of a public method, or wrap it with extra logic.
- Use a preference when you implement an interface, or as a last resort when you must replace a class and nothing else works.
The rest of the article is the reasoning behind that list.
Observers: reacting to events
Magento dispatches named events through Magento\Framework\Event\ManagerInterface. Some are explicit, such as checkout_cart_product_add_after or sales_order_place_after. Others are generated for models, such as catalog_product_save_after, built from the model's $_eventPrefix. You subscribe in etc/events.xml, or in an area folder like etc/frontend/events.xml or etc/adminhtml/events.xml to limit where the observer runs.
<?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="acme_order_export_queue"
instance="Acme\OrderExport\Observer\QueueOrderExport"/>
</event>
</config>
<?php
declare(strict_types=1);
namespace Acme\OrderExport\Observer;
use Acme\OrderExport\Model\ExportQueue;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\Data\OrderInterface;
class QueueOrderExport implements ObserverInterface
{
public function __construct(
private readonly ExportQueue $exportQueue
) {
}
public function execute(Observer $observer): void
{
/** @var OrderInterface $order */
$order = $observer->getEvent()->getData('order');
$this->exportQueue->add((int) $order->getEntityId());
}
}
What observers are good at
- Side effects: logging, queueing a message, sending data to an ERP, clearing a custom cache.
- Many listeners on the same moment without them knowing about each other.
- Code that should be easy to disable. Adding
disabled="true"to an observer node in another module'sevents.xmlturns it off.
Where observers fall short
- You can only act where an event exists. If the core does not dispatch one, an observer is not an option.
- Observers do not return values. You can change objects passed by reference in the event data, but the code that dispatched the event decides what it does with them afterwards. Relying on mutation is fragile.
- Execution order between observers of the same event is not something you should depend on.
- Heavy work inside an observer runs synchronously in the request. Calling an external API in
sales_order_place_aftermakes checkout as slow as that API. Queue the work instead and process it with a consumer or cron.
Plugins: intercepting public methods
A plugin wraps a public method of a class or interface. Magento generates an Interceptor subclass in generated/code that calls your plugin methods around the original. You declare plugins in 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\Checkout\Model\Cart">
<plugin name="acme_cart_max_qty"
type="Acme\CartRules\Plugin\LimitQtyPlugin"
sortOrder="20"/>
</type>
</config>
Before, after and around
The method prefix decides the plugin type. The first argument is always the intercepted object.
<?php
declare(strict_types=1);
namespace Acme\CartRules\Plugin;
use Magento\Checkout\Model\Cart;
use Magento\Framework\Exception\LocalizedException;
class LimitQtyPlugin
{
private const MAX_QTY = 10;
public function beforeAddProduct(Cart $subject, $productInfo, $requestInfo = null): array
{
if (is_array($requestInfo) && isset($requestInfo['qty']) && (float) $requestInfo['qty'] > self::MAX_QTY) {
throw new LocalizedException(__('You can add at most %1 units at once.', self::MAX_QTY));
}
return [$productInfo, $requestInfo];
}
}
- before methods receive the original arguments and may return an array of modified arguments, or
nullto leave them unchanged. - after methods receive the result, then the original arguments, and must return the result. In
afterGetPrice(Product $subject, $result)the second parameter is the return value ofgetPrice(). - around methods receive a
callable $proceedand decide whether and how to call the original.
public function aroundSave(
\Magento\Customer\Model\ResourceModel\Customer $subject,
callable $proceed,
\Magento\Framework\Model\AbstractModel $customer
) {
$start = microtime(true);
$result = $proceed($customer);
$this->logger->debug('Customer save took ' . round(microtime(true) - $start, 3) . 's');
return $result;
}
Avoid around plugins when you can
Around plugins add stack depth and make debugging harder, and an around plugin that forgets to call $proceed silently stops the original method and every plugin with a higher sort order. Adobe's own guidance is to prefer before and after. Most around plugins could be written as a before plus an after. The real use cases are skipping the original call conditionally or wrapping it in a try and catch or a transaction.
Sort order
sortOrder controls the sequence when several plugins hit the same method. Before plugins run from the lowest to the highest sort order, and so does the part of an around plugin before $proceed. After plugins and the part of an around plugin after $proceed unwind in the opposite direction. When two extensions conflict on the same method, sort order is usually the first thing to look at.
What plugins cannot intercept
- Final methods and final classes.
- Non public methods (protected and private).
- Static methods.
__constructand__destruct.- Virtual types directly. Plug into the real class instead.
- Objects created with
newrather than by the object manager, and objects instantiated before the interception framework is bootstrapped.
If the method you want is protected, look one level up for the public method that calls it. That is usually a better integration point anyway.
Plug into interfaces, not implementations
Where Magento uses service contracts, declare the plugin on the interface, for example Magento\Catalog\Api\ProductRepositoryInterface. The plugin then applies to whatever implementation is configured, including one from another module, and your code relies on the stable API rather than an internal class.
Preferences: replacing a class
A preference tells the object manager to create a different class whenever a given class or interface is requested.
<preference for="Acme\Shipping\Api\RateProviderInterface"
type="Acme\Shipping\Model\RateProvider"/>
That use, mapping an interface to its implementation, is the normal and correct one. Magento itself is built on thousands of these in app/etc/di.xml and module di.xml files.
The risky use is overriding a concrete core class:
<preference for="Magento\Catalog\Model\Product\Url"
type="Acme\Catalog\Model\Product\Url"/>
Why class preferences are a last resort
- Only one preference wins. If two extensions both replace the same class, one of them is silently ignored, decided by module load order.
- Your class usually extends the core class and copies one method. On upgrade the core method changes and your copy does not, so you keep old behaviour or break on a changed signature.
- Plugins on the original class still apply to your subclass, which surprises people who expected a clean replacement.
I use a class preference when the behaviour I need lives in a protected method that no public method lets me reach in a clean way, and the alternative is patching vendor code. Even then I keep the overriding method as small as possible and add a test that fails if the parent signature changes.
Constructor arguments: the option people forget
Many changes need none of the three. di.xml lets you change the arguments a class receives. Adding an item to a pool, swapping one dependency for a specific class, or creating a virtual type with different settings is cleaner than any plugin.
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="acme_import_run" xsi:type="object">Acme\Import\Console\Command\RunImport</item>
</argument>
</arguments>
</type>
<virtualType name="Acme\Import\Logger\Handler" type="Magento\Framework\Logger\Handler\Base">
<arguments>
<argument name="fileName" xsi:type="string">/var/log/acme_import.log</argument>
</arguments>
</virtualType>
This is exactly how modules register CLI commands. Core pools such as command lists, layout processors and data providers are designed for this. Before writing a plugin, check whether the class you want to change accepts an array or object argument you can extend.
A decision checklist
- Is the behaviour configurable through a
di.xmlargument or pool? Use that. - Is there an event at the right moment and you only need a side effect? Use an observer, and make it fast or queue the work.
- Do you need to change the arguments or result of a public method? Use a before or after plugin, on the interface when there is one.
- Do you need to skip or wrap the original call? Use an around plugin and keep it short.
- Are you implementing an interface? Use a preference.
- Nothing above works and the alternative is a core patch? A class preference, small and well tested, or a composer patch with a clear note on why.
Debugging what is already there
On an inherited store the first job is finding out what already hooks into the code you are about to change. A few commands help:
bin/magento dev:di:info "Magento\Checkout\Model\Cart"
grep -rn "sales_order_place_after" app/code vendor --include=events.xml
dev:di:info lists the preference, constructor arguments and plugins configured for a class, which is often enough to explain why a method does not behave the way the core code suggests. Also remember that di.xml in etc/ is global while etc/frontend/di.xml, etc/adminhtml/di.xml and etc/webapi_rest/di.xml only apply to their area, so the same class can behave differently on the storefront and in the REST API.
FAQ
Are plugins faster than observers in Magento 2?
The overhead of either mechanism is small. What makes a difference is the work done inside them. Pick the one that fits the job, and keep slow work such as API calls out of the request by queueing it.
Can a plugin intercept a protected or private method?
No. Plugins only work on public methods. They also cannot intercept final methods, final classes, static methods, constructors or virtual types. Look for the public method that calls the protected one.
What happens when two modules set a preference for the same class?
Only one wins, based on module load order, and the other is ignored without any error. That is the main reason to avoid class preferences for core classes when a plugin can do the job.
How do I see which plugins are attached to a class?
Run bin/magento dev:di:info with the class name. It lists the preference, constructor arguments and plugins configured for that class, including their sort order.
I am available for freelance Magento and Adobe Commerce work, from module development and performance audits to migrations and Hyvä builds. If that sounds useful, get in touch.
Related articles
Magento 2 Cache Types Explained: Clean, Flush, Custom
What each Magento 2 cache type stores, the real difference between cache:clean and cache:flush, invalidated caches, and how to add your own cache type.
Magento 2 Cart Price Rules: Add a Custom Condition
Add a custom condition to Magento 2 cart price rules: the combine event, a condition model with validate, promo rule attributes and performance traps to avoid.
How to Create a Custom CLI Command in Magento 2
Create a custom bin/magento command in Magento 2.4.8: registration in di.xml, arguments and options, area code, proxies, progress bars, locks and exit codes.
Magento 2 Custom Email Templates: Declare, Send and Override
Create and send custom email templates in Magento 2.4: email_templates.xml, template syntax in strict mode, TransportBuilder, admin overrides and theme files.