Magento 2 Events, Observers and Plugins: Which to Use When (Decision Table Included)
Six ways to intercept Magento 2, one decision table. Real observer and plugin code, the grep that lists every event a module dispatches, and three production bugs caused by picking the wrong tool.
Magento 2 gives you six ways to change core behavior: observers, before plugins, after plugins, around plugins, preferences, and the honest answer of "none of the above, restructure". Most tutorials list the syntax for each and stop, which is how stores end up with around plugins on hot paths and preferences fighting each other. This guide starts with the decision, then shows working code for each option, then walks through three production bugs I was paid to fix because someone picked the wrong tool. Everything here is tested on Magento 2.4.9, PHP 8.4 (July 2026).
Magento 2 plugin vs observer vs preference: the 60 second answer
Ask one question first: do you need to change what the original code returns, or do you only need to know that it ran? If you only need to know, use an observer. If you need to change arguments or results, use a before or after plugin. Everything else is an edge case, and the table below covers those edges.
| Situation | Reach for | Why |
|---|---|---|
| React to a completed action: order placed, customer registered, product saved | Observer | Fires after the fact, cannot corrupt the return value, fully decoupled from the caller |
| Validate or rewrite a method's input arguments | Before plugin | Runs first and returns the modified argument array; the original method stays untouched |
| Change or enrich what a public method returns | After plugin | Receives the result, transforms it, passes it on; composes cleanly with other extensions |
| Conditionally skip or replace the original call entirely | Around plugin | The only option that can short-circuit via $proceed; you pay in cache behavior and stack-trace readability |
| Replace an entire class implementation, every method | Preference | Global and exclusive; two modules preferring the same class means one silently loses |
| Change a template, add data to a block, tweak layout | None of the above | Use a ViewModel, layout XML, or a theme override; interception is the wrong layer |
An after plugin on a service contract method is the default answer for "change what Magento returns". An observer is the default for "tell me when this happened". Around plugins and preferences should each come with a written justification in the pull request description, because six months from now somebody will pay for them.
A working Magento 2 observer example (events.xml plus the class)
Here is the full wiring for a common case: run code when a customer lands on the checkout success page. Two files inside your module. If the module skeleton itself is new to you, my create a custom module from scratch walkthrough covers registration.php and module.xml, so I will skip those here.
First, etc/frontend/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="checkout_onepage_controller_success_action">
<observer name="panth_orderflow_success"
instance="Panth\OrderFlow\Observer\OrderSuccess"/>
</event>
</config>
Then the observer class:
<?php
declare(strict_types=1);
namespace Panth\OrderFlow\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Psr\Log\LoggerInterface;
class OrderSuccess implements ObserverInterface
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly LoggerInterface $logger
) {
}
public function execute(Observer $observer): void
{
$orderIds = $observer->getEvent()->getData('order_ids');
if (empty($orderIds)) {
return;
}
$order = $this->orderRepository->get((int) $orderIds[0]);
$this->logger->info('panth_orderflow: order placed', [
'increment_id' => $order->getIncrementId(),
]);
// Queue the ERP push here. Never make a blocking HTTP call
// inside an observer that sits on the success page.
}
}
Observer area scope: frontend, adminhtml or global
The directory the events.xml sits in decides where the observer runs. This is the single most common observer mistake I see in code review:
etc/events.xml: every area. Storefront, admin, REST, GraphQL, cron, CLI.etc/frontend/events.xml: storefront requests only.etc/adminhtml/events.xml: admin panel only.etc/webapi_rest,etc/graphql,etc/crontab: those entry points only.
Register the observer in the narrowest area that does the job. A global observer on a save event runs during CSV imports, cron jobs and API syncs whether you meant it to or not. Bug number two below is exactly this mistake in production.
How to dispatch a custom event in Magento 2
Your own modules can fire events too, and it is the cheapest extension point you will ever ship. Inject Magento\Framework\Event\ManagerInterface and dispatch with a data array:
<?php
declare(strict_types=1);
namespace Panth\Loyalty\Service;
use Magento\Framework\Event\ManagerInterface;
class PointsAwarder
{
public function __construct(
private readonly ManagerInterface $eventManager
) {
}
public function award(int $customerId, int $points): void
{
// ... persist the points transaction ...
$this->eventManager->dispatch('panth_loyalty_points_awarded', [
'customer_id' => $customerId,
'points' => $points,
]);
}
}
Any module can now observe panth_loyalty_points_awarded exactly like a core event, and the observer pulls customer_id and points from $observer->getEvent(). Two naming rules keep you out of trouble: prefix with your vendor name so you never shadow a core event, and stick to lowercase snake_case because that is what every tooling assumption expects.
If you build modules for distribution, dispatching events at your own decision points is what separates an extension people can integrate with from one they have to patch. It is a standard part of every module I build under my Magento extension development service.
How to find the Magento 2 events list with one grep
There is no bin/magento events:list command, and third-party reference pages go stale. The codebase is the ground truth, and grep reads it in seconds:
# Every literal event dispatched by the checkout module
grep -rn "dispatch(" vendor/magento/module-checkout --include='*.php' | grep -v Test
# Unique event names across a whole module, one per line
grep -rhoE "dispatch\(\s*'[a-z0-9_]+'" vendor/magento/module-sales | sort -u
The first command shows every dispatch call with file and line number, so you can open the exact spot and see what data the event carries. The second gives you a deduplicated event name list for a whole module.
The eventPrefix trick: why grep misses save events
Grep for catalog_product_save_after in vendor and you get nothing, yet the event definitely fires. That is because models extending AbstractModel build event names at runtime from a prefix: $_eventPrefix = 'catalog_product' plus a suffix like _save_before, _save_after, _delete_after or _load_after. Collections do the same with _collection_load_after. So the second grep you need is:
grep -rn "_eventPrefix" vendor/magento/module-catalog/Model | head
Take each prefix you find, append the standard suffixes, and you have the model events that never appear as literal strings anywhere in the code.
Magento 2 before, after and around plugin examples
Plugins attach to public methods of classes the object manager resolves. Declare them in etc/di.xml (or an area-specific di.xml, same scoping logic as observers):
<?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\Api\ProductRepositoryInterface">
<plugin name="panth_pricing_product_repository"
type="Panth\Pricing\Plugin\ProductRepositoryPlugin"
sortOrder="20"/>
</type>
</config>
One plugin class can carry all three method types. Here are before and after, the two you should reach for first:
<?php
declare(strict_types=1);
namespace Panth\Pricing\Plugin;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
class ProductRepositoryPlugin
{
/**
* Normalize the SKU before every save.
*/
public function beforeSave(
ProductRepositoryInterface $subject,
ProductInterface $product,
$saveOptions = false
): array {
if ($product->getSku()) {
$product->setSku(strtoupper(trim((string) $product->getSku())));
}
return [$product, $saveOptions];
}
/**
* Flag zero-price products after every load.
*/
public function afterGet(
ProductRepositoryInterface $subject,
ProductInterface $result
): ProductInterface {
if ((float) $result->getPrice() === 0.0) {
$result->setCustomAttribute('panth_price_flag', 'needs_review');
}
return $result;
}
}
And an around plugin, shown deliberately last. The callable $proceed argument is the rest of the call chain; you decide whether it runs:
public function aroundDeleteById(
ProductRepositoryInterface $subject,
callable $proceed,
string $sku
): bool {
if ($this->protectedSkuList->contains($sku)) {
throw new \Magento\Framework\Exception\CouldNotDeleteException(
__('SKU %1 is protected and cannot be deleted.', $sku)
);
}
return $proceed($sku); // forget this line and deletes silently stop working
}
sortOrder and how multiple plugins stack
When several modules plug the same method, sortOrder decides the sequence: before methods run in ascending order, the around plugin with the lowest sortOrder wraps the others, and on the way back out the after methods unwind in the opposite direction. If your plugin must see another extension's changes, give it a higher sortOrder and document why in the di.xml comment, because nobody will guess it later.
What plugins cannot intercept
- Final classes and final methods.
- Private and protected methods. Public only.
- Constructors and static methods.
- Objects created with
newinstead of DI, and classes instantiated before the interception layer boots. - Virtual types.
When you hit one of these walls, resist the jump straight to a preference. Often there is a public method one level up the call stack that gives you a clean plugin point.
Three production bugs from choosing the wrong tool
These are real tickets from client work, anonymized. Each one traces back to the decision table above being ignored.
Bug 1: the around plugin that killed full-page cache
A fraud-screening extension shipped an around plugin on the front controller's dispatch method to attach a device fingerprint cookie. Inside the plugin it read the customer session to decide the cookie contents. Reading the session starts the session, on every request, including the anonymous ones Varnish should have served from cache. The vary context shifted, responses stopped being cacheable, and the Varnish hit rate graph fell off a cliff within an hour of the deploy while origin CPU climbed.
The fix: the logic did not need to change any return value, so it did not need a plugin at all. It became an observer on a layout render event for the handful of pages that actually needed the cookie, with the session read replaced by customer-data (private content) on the client side. Cache hit rate recovered the same day.
Bug 2: the observer that fired on admin saves
A store needed a geo-based redirect for storefront visitors. The developer wired an observer on controller_action_predispatch, but placed it in etc/events.xml, the global scope. It worked in every storefront test. Then the merchandising team started reporting that product saves "did not stick": the observer was also intercepting admin POST requests and redirecting them mid-save. Nobody connected the two symptoms for days because the redirect module and the catalog have nothing to do with each other.
The fix was moving one file to etc/frontend/events.xml and flushing config cache. The lesson costs nothing to apply: area scope is part of the observer design, not an afterthought.
Bug 3: the preference collision that surfaced at setup:di:compile
Two checkout extensions from different vendors each declared a preference for the same core shipping management class. In developer mode with lazy resolution, the module that loaded later simply won, and the losing extension's feature quietly stopped working; nobody noticed because checkout still functioned. The collision only became loud during a production deploy, when setup:di:compile validated constructor signatures across the DI graph and failed the build on the winning class, which extended an older core constructor.
The fix took a day: both preferences were rewritten as after plugins on the specific public methods each vendor actually cared about. Plugins compose, preferences do not. If you take one rule from this whole post, take that one.
Performance: what plugins and observers actually cost
Neither mechanism is expensive by itself, and micro-benchmarking them is usually a distraction. What matters is where you attach them.
Plugins. During compilation Magento generates an Interceptor subclass for every plugged type; that is a build-time cost, not a runtime one. At runtime each call to a plugged method resolves the plugin list and walks it. For a repository method called a few times per request this is noise. For a getter called hundreds of times inside a category listing loop, a careless plugin becomes a real profiler line. Attach plugins to coarse service contract methods, not hot low-level getters.
Observers. A dispatch with zero registered observers is close to free: the event manager checks merged config and moves on. The cost is your observer body. An observer on controller_action_predispatch runs on literally every request, so a synchronous HTTP call there is a site-wide slowdown. Push slow work to a message queue and keep the observer itself to a few milliseconds.
Around plugins. Beyond the closure chain they build, their real cost is operational: they hide the original call behind your code, they make stack traces longer and stranger, and as bug 1 showed, one session read inside them can defeat full-page cache entirely. Treat every around plugin as a small standing liability.
Rule of thumb from years of extension audits: after plugin on a service contract, observer for side effects, queue for anything slow. Stores that follow those three clauses almost never appear in my profiler with interception problems.
Frequently asked questions
Can I use an observer instead of a plugin in Magento 2?
Only when you do not need to change the return value. Observers react to events after the fact and cannot modify what the original method returns to its caller. If you need to alter arguments or results, you need a before or after plugin. If you only need to know something happened, an observer is the cleaner, more decoupled choice.
How do I find which events Magento 2 dispatches?
Grep the source: grep -rn "dispatch(" vendor/magento/module-name lists every literal dispatch with file and line. For model save and load events, also grep for _eventPrefix, because AbstractModel builds names like catalog_product_save_after at runtime and they never appear as literal strings.
Are around plugins bad for performance?
The mechanism itself is not slow, but around plugins carry the highest risk per line of code: they can short-circuit core logic, break full-page cache if they touch the session or cookies, and make stack traces hard to read. Use one only when you must conditionally replace the original call, and confirm a before or after plugin cannot do the job.
What is the difference between a plugin and a preference in Magento 2?
A plugin wraps individual public methods and composes with other modules' plugins through sortOrder. A preference replaces the entire class globally and exclusively: if two modules declare a preference for the same class, only one wins, and the loser fails silently. Prefer plugins in almost every case.
Why is my Magento 2 observer not firing?
Check three things in order: the events.xml sits in the right area directory for where the action happens (global vs frontend vs adminhtml), the config cache has been flushed since you added it, and the event name is exactly right, including eventPrefix-generated names. A typo in the event name fails silently with no error anywhere.
Can other modules observe an event my own module dispatches?
Yes. A custom event dispatched through ManagerInterface behaves exactly like a core event: any module can register an observer for it in its own events.xml. That is the whole point of dispatching your own events, and it is the cheapest integration surface you can offer other developers.
Fighting a pile of conflicting plugins, observers and preferences? I audit extension conflicts and build Magento 2 modules that pass code review the first time, with every interception choice documented. Fixed fee from $499 audit · $2,499 sprint · ~6h @ $25/hr for a typical interception cleanup.
Book an extension audit