DEV Community

Magevanta
Magevanta

Posted on • Originally published at magevanta.com

Magento 2 Plugins & Observers: Avoiding Hidden Performance Bottlenecks

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Better — use after instead:

public function afterGetName(Product $subject, string $result): string
{
    return strtoupper($result);
}
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Third-party modules register observers on high-frequency events like catalog_product_load_after, layout_generate_blocks_after, or controller_action_predispatch
  2. Observers do heavy work: database queries, external API calls, file I/O
  3. 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_after to inject widgets — can add 30–100ms per page
  • Loyalty / reward point modules adding observers on sales_order_place_after and checkout_cart_product_add_after, sometimes with synchronous API calls
  • Custom attribute modules stacking around plugins on getAttribute() — 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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:info before adding new plugins
  • Profile with Blackfire or Xdebug to find actual hot paths
  • Prefer after over around wherever 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)