Magento 2's plugin and observer systems are among its most powerful extension mechanisms. They let you hook into virtually any public method or event without modifying core code. But with great power comes great responsibility — and a surprising number of production sites suffer from slow page loads, high TTFB, and sluggish admin panels because of poorly written plugins and observers scattered across installed modules.
This post digs into how these extension points work under the hood, how to measure their impact, and what you can do to keep your stack lean and fast.
How Plugins Work Internally
Every time you define a <plugin> in di.xml, Magento generates an interceptor class that wraps the original class. On the first request (or after setup:di:compile), the framework builds these proxy classes in generated/code/.
An interceptor works roughly like this:
// Generated interceptor pseudocode
public function someMethod(...$args) {
// call all "before" plugins
foreach ($this->pluginList->getNext('before') as $plugin) {
$args = $plugin->beforeSomeMethod($this, ...$args) ?? $args;
}
// call "around" plugins (recursive chain)
$result = $aroundPlugin->aroundSomeMethod($this, $proceed, ...$args);
// call all "after" plugins
foreach ($this->pluginList->getNext('after') as $plugin) {
$result = $plugin->afterSomeMethod($this, $result, ...$args) ?? $result;
}
return $result;
}
The cost here is not negligible. Every plugin adds method calls, closure creation, and argument passing. When a method like \Magento\Catalog\Model\Product::getData() — called hundreds of times per page — has five interceptors stacked on it, the overhead compounds quickly.
Around Plugins: The Most Dangerous Type
around plugins are the heaviest interceptor type. They wrap the original method in a closure ($proceed), meaning:
- The full call stack depth increases
- PHP must maintain additional stack frames
- Closures are slower than direct method calls
- Other plugins in the chain still run through the same closure
The rule of thumb: Only use around when you need to conditionally skip the original method entirely. For everything else, prefer before or after.
Bad pattern — using around just to modify the result:
// Don't do this
public function aroundGetName(Product $subject, callable $proceed): string
{
$name = $proceed();
return strtoupper($name);
}
Better — use after instead:
public function afterGetName(Product $subject, string $result): string
{
return strtoupper($result);
}
The difference is small per call, but if this runs on a category page listing 48 products, you've already added 48 unnecessary closure invocations.
Profiling Plugin Overhead
The fastest way to spot plugin bloat is to use the built-in Magento profiler or Blackfire/Xdebug.
Enable the built-in profiler:
# In pub/index.php, add at the top:
\Magento\Framework\Profiler::start('root');
# Or via env variable:
MAGE_PROFILER=html php bin/magento ...
Look for methods that appear hundreds of times in the call graph. Then cross-reference with generated/code/ to see how many interceptor layers they have.
Using the CLI to list active plugins:
# Find all plugins registered for a class
bin/magento dev:di:info "Magento\Catalog\Model\Product"
This outputs a table of all plugins, their types (before/around/after), and sort order. If you see 10+ plugins on a hot method, that's your culprit.
How Observers Add Up
Observers work differently — they're event-based and asynchronous in feel, but they run synchronously in the same PHP process. Every $eventManager->dispatch('catalog_product_load_after', ...) iterates all registered observers for that event.
The problem compounds when:
-
Third-party modules register observers on high-frequency events like
catalog_product_load_after,layout_generate_blocks_after, orcontroller_action_predispatch - Observers do heavy work: database queries, external API calls, file I/O
- Observers aren't guarded: they run on every store, every customer group, every page load
Check your events.xml files:
find app/code vendor/*/module-* -name "events.xml" | \
xargs grep -l "catalog_product_load_after\|layout_generate_blocks_after" 2>/dev/null
Pay attention to any observer that does a $this->_objectManager->get(...) or fires a new SQL query. These are red flags.
Disabling Modules and Plugins Surgically
If a third-party module registers plugins you don't need, you have two options:
Option 1 — Disable a specific plugin in your own module's di.xml:
<type name="Magento\Catalog\Model\Product">
<plugin name="ThirdParty_Module::somePlugin" disabled="true" />
</type>
This is clean and upgrade-safe. No core files modified.
Option 2 — Disable the entire module:
bin/magento module:disable ThirdParty_Module
bin/magento setup:upgrade
bin/magento setup:di:compile
Only do this if you've verified the module is truly unused.
Common Offenders in Popular Extensions
From community benchmarks and profiling sessions, these patterns frequently appear:
-
Page builders hooking into
layout_generate_blocks_afterto inject widgets — can add 30–100ms per page -
Loyalty / reward point modules adding observers on
sales_order_place_afterandcheckout_cart_product_add_after, sometimes with synchronous API calls -
Custom attribute modules stacking
aroundplugins ongetAttribute()— called dozens of times per product render -
Translation/locale plugins wrapping
__()— the most-called method in Magento, potentially millions of times per request in heavy catalog pages
Write Efficient Plugins: A Checklist
When writing your own plugins, follow these rules:
1. Prefer after over around
Only use around if you need to conditionally prevent the original call.
2. Check the call frequency
Before adding a plugin, ask: how many times is this method called per page request? A plugin on Product::getId() is very different from one on Cart::addProduct().
3. Guard with early returns
If your plugin logic only applies in certain contexts, bail out early:
public function afterGetPrice(Product $subject, float $result): float
{
if ($subject->getTypeId() !== 'simple') {
return $result;
}
// ... custom logic
}
4. Never do I/O in observers on hot events
If you must call an API or run a query, push it to a message queue instead:
// In your observer
$this->publisher->publish('my.topic', $data);
5. Set sort order intentionally
Plugins run in sort order. Default is 10. If your plugin must run first or last, set it explicitly. Avoid fighting with other modules' sort orders by profiling the chain.
6. Avoid ObjectManager in plugins/observers
Injecting via constructor is faster and more cacheable. Using ObjectManager::get() inside a hot path bypasses DI caching benefits.
Measuring Before and After
Before deploying any optimization, establish a baseline. A simple approach:
# Measure with wrk or Apache Bench
ab -n 100 -c 5 https://yourstore.com/catalog/category/view/id/4
# Record: Requests per second, mean response time, 99th percentile
After disabling a plugin or observer, re-run the same test. A well-placed optimization on a high-frequency event can yield 50–200ms improvements on category and product pages.
Summary
Plugins and observers are not free. Each one adds method calls, stack frames, and potential I/O to your critical path. The Magento ecosystem has hundreds of modules, many of which pile interceptors on the same hot methods.
The fix isn't to avoid plugins — it's to use them precisely:
-
Audit with
dev:di:infobefore adding new plugins - Profile with Blackfire or Xdebug to find actual hot paths
-
Prefer
afteroveraroundwherever possible -
Disable unused third-party plugins surgically via
di.xml - Push heavy work to queues instead of running it synchronously in observers
Your users won't see your clever plugin architecture — they'll only feel whether the page loads fast or slow. Make every interceptor earn its place.
Top comments (0)