Preferences vs. Plugins in Magento 2: Which Extension Strategy When? | Mironsoft
AI generated
EXTEND
DI
Deep Dive · Magento 2 Extensibility

Preferences vs. Plugins:
Which Extension Strategy When?

How interceptors work internally, why Preferences are usually the wrong choice, and the exact rules for Before, After, and Around plugins.

15 min read
Magento 2.4.8 · PHP 8.4
Magento Extension Points

One of the most common questions in Magento code reviews: "Should I use a Preference or a Plugin?" The answer is almost always Plugin, but why, and which type? This deep dive shows the exact differences, the internal mechanisms, and the situations where Preferences still make sense.

1. Two ways to extend Magento

Magento offers two primary ways to modify existing code:

Mechanism How it works Scales with other modules?
Preference Replaces the entire class with your own ✗ No, conflicts are unavoidable
Plugin (Before/After/Around) Inserts code before/after/around a method ✓ Yes, multiple plugins on the same method
Observer Reacts to Magento events ✓ Yes, multiple observers per event
Event + Plugin combined Plugin dispatches its own event for further extension ✓ Yes, maximum flexibility

2. Preferences: replacing a class via di.xml

A Preference tells the DI container: "If someone requests class A, give them class B instead":


<!-- app/code/Mironsoft/Catalog/etc/di.xml -->
<config>
    <!-- PREFERENCE: replaces ProductRepository entirely -->
    <preference
        for="Magento\Catalog\Model\ProductRepository"
        type="Mironsoft\Catalog\Model\ProductRepository"
    />
</config>
    

<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Model;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\Data\ProductSearchResultsInterface;
use Magento\Framework\Api\SearchCriteriaInterface;

/**
 * Custom ProductRepository that overrides Magento's implementation.
 * WARNING: This approach causes conflicts with other modules.
 */
class ProductRepository extends \Magento\Catalog\Model\ProductRepository
{
    /**
     * Override getById to add custom caching logic.
     * Must re-implement or call parent, risky if parent changes.
     */
    public function getById(
        int $productId,
        bool $editMode = false,
        ?int $storeId = null,
        bool $forceReload = false
    ): ProductInterface {
        // Custom logic before
        $product = parent::getById($productId, $editMode, $storeId, $forceReload);
        // Custom logic after
        return $product;
    }
}
    

3. Why Preferences are problematic

The core problem: only one Preference can be active for a class. If two modules override the same class, the one loaded last wins:


Module A: preference for="ProductRepository" type="A\ProductRepository"
Module B: preference for="ProductRepository" type="B\ProductRepository"

→ Result: B\ProductRepository wins
→ All changes from A\ProductRepository are lost
→ Module A's plugin now targets B\ProductRepository, it may not work
    

Preference risks summarized

  • Conflict with other modules: If 2 modules override the same class, one of them loses
  • Inheritance coupling: If the parent class changes its constructor arguments, your class breaks
  • No composability: Other modules cannot extend your Preference "on top"
  • Maintenance overhead: Every Magento update requires checking whether the parent class has changed

When Preferences still make sense:

  • Interface binding: <preference for="InterfaceA" type="ConcreteImpl"/>, that's actually not an override but an implementation
  • Your own interface implementation, not overriding core classes
  • When the class is final and plugins don't work (in that case it's a Magento bug)

4. Plugins (interceptors): the Interceptor Pattern

Plugins are Magento's implementation of the Interceptor Pattern (also known as the Decorator Pattern). The DI container automatically generates an interceptor class:


Your class: ProductRepository
Magento generates: ProductRepository\Interceptor (in generated/)

This interceptor class overrides ALL public methods
and calls registered plugins in the correct order:

Plugin call stack for save():
1. BeforePlugin::beforeSave($subject, ...$args)      ← sortOrder: 10
2. BeforePlugin2::beforeSave($subject, ...$args)     ← sortOrder: 20
3. AroundPlugin::aroundSave($subject, $proceed, ...) ← sortOrder: 15
   └→ $proceed() → original save()
4. AfterPlugin::afterSave($subject, $result)         ← sortOrder: 5
5. AfterPlugin2::afterSave($subject, $result)        ← sortOrder: 30
    

Registering a plugin in di.xml:


<!-- app/code/Mironsoft/Catalog/etc/di.xml -->
<config>
    <type name="Magento\Catalog\Api\ProductRepositoryInterface">
        <plugin
            name="mironsoft_catalog_product_repository"
            type="Mironsoft\Catalog\Plugin\ProductRepositoryPlugin"
            sortOrder="10"
            disabled="false"
        />
    </type>
</config>
    

5. Before plugin: modifying arguments

Before plugins can modify a method's input arguments before it executes:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;

/**
 * Before plugin: Modifies input arguments before the original method runs.
 * Method name: before + MethodName (camelCase)
 */
final class ProductRepositoryBeforePlugin
{
    /**
     * Validates and sanitizes product data before save.
     *
     * @param ProductRepositoryInterface $subject  The original object
     * @param ProductInterface $product            Original first argument
     * @param bool $saveOptions                    Original second argument
     * @return array|null  Return array to replace args, null to keep original
     */
    public function beforeSave(
        ProductRepositoryInterface $subject,
        ProductInterface $product,
        bool $saveOptions = false,
    ): array|null {
        // Sanitize SKU: remove special characters
        $cleanSku = preg_replace('/[^A-Za-z0-9\-_]/', '', $product->getSku());
        $product->setSku(strtoupper($cleanSku));

        // Return modified arguments, MUST be an array matching method signature
        return [$product, $saveOptions];

        // Return null to leave arguments unchanged (alternative)
        // return null;
    }

    /**
     * Ensures product name is not empty before load.
     */
    public function beforeGetById(
        ProductRepositoryInterface $subject,
        int $productId,
        bool $editMode = false,
        ?int $storeId = null,
        bool $forceReload = false,
    ): array|null {
        // Log all product lookups for auditing
        if ($storeId === null) {
            $storeId = 1; // Default to store 1 if not specified
        }

        return [$productId, $editMode, $storeId, $forceReload];
    }
}
    

Important rules for Before plugins:

  • Method name: before + PascalCase of the original method
  • First parameter is always $subject (the original object)
  • Followed by all parameters of the original method
  • Return value: array with new arguments or null for unchanged
  • No access to the return value, only arguments can be modified

6. After plugin: modifying the return value

After plugins can modify a method's result after it has executed:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;

/**
 * After plugin: Modifies the return value after the original method ran.
 * Method name: after + MethodName (camelCase)
 */
final class ProductRepositoryAfterPlugin
{
    /**
     * Enriches product data after loading from repository.
     *
     * @param ProductRepositoryInterface $subject  The original object
     * @param ProductInterface $result             The return value of getById()
     * @param int $productId                       Original argument (optional in after)
     * @return ProductInterface  Modified (or same) return value
     */
    public function afterGetById(
        ProductRepositoryInterface $subject,
        ProductInterface $result,
        int $productId,           // Original arguments are OPTIONAL in after-plugins
        bool $editMode = false,
    ): ProductInterface {
        // Add custom extension attribute
        $extensionAttributes = $result->getExtensionAttributes();
        $extensionAttributes->setCustomScore($this->calculateScore($result));
        $result->setExtensionAttributes($extensionAttributes);

        return $result; // Must return the (modified) result
    }

    /**
     * Adds total count to search results after getList().
     *
     * @param ProductRepositoryInterface $subject
     * @param \Magento\Catalog\Api\Data\ProductSearchResultsInterface $result
     * @return \Magento\Catalog\Api\Data\ProductSearchResultsInterface
     */
    public function afterGetList(
        ProductRepositoryInterface $subject,
        \Magento\Catalog\Api\Data\ProductSearchResultsInterface $result,
    ): \Magento\Catalog\Api\Data\ProductSearchResultsInterface {
        // Add metadata to all returned products
        foreach ($result->getItems() as $product) {
            $product->setCustomAttribute('processed_at', date('Y-m-d H:i:s'));
        }

        return $result;
    }

    private function calculateScore(\Magento\Catalog\Api\Data\ProductInterface $product): float
    {
        return (float) $product->getPrice() * (float) ($product->getRating() ?? 1.0);
    }
}
    

Important rules for After plugins:

  • Method name: after + PascalCase of the original method
  • First parameter: $subject, second: $result (return value)
  • Original arguments follow (all optional)
  • Must return the (possibly modified) return value
  • For void methods: no return value needed (return void)

7. Around plugin: full control (and risks)

Around plugins wrap the entire method, they have full control, but they also carry the highest risk:


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;

/**
 * Around plugin: Wraps the entire method execution.
 * Use sparingly, can break the call chain if $proceed is not called.
 */
final class ProductRepositoryAroundPlugin
{
    public function __construct(
        private readonly \Psr\Log\LoggerInterface $logger,
        private readonly \Magento\Framework\App\CacheInterface $cache,
    ) {}

    /**
     * Adds caching layer around getById().
     *
     * @param ProductRepositoryInterface $subject  Original object
     * @param callable $proceed                    Call $proceed(...$args) to continue chain
     * @param int $productId                       Original arguments
     */
    public function aroundGetById(
        ProductRepositoryInterface $subject,
        callable $proceed,
        int $productId,
        bool $editMode = false,
        ?int $storeId = null,
        bool $forceReload = false,
    ): ProductInterface {
        $cacheKey = "product_{$productId}_{$storeId}";

        // Check cache before calling original
        if (!$forceReload && $cached = $this->cache->load($cacheKey)) {
            return unserialize($cached);
        }

        $startTime = microtime(true);

        // CRITICAL: Call $proceed() to continue the plugin chain
        // If you don't call this, the original method AND all other plugins are skipped!
        $result = $proceed($productId, $editMode, $storeId, $forceReload);

        $duration = round((microtime(true) - $startTime) * 1000, 2);
        $this->logger->debug("Product load took {$duration}ms", ['id' => $productId]);

        // Store in cache
        $this->cache->save(serialize($result), $cacheKey, ['catalog_product'], 3600);

        return $result;
    }
}
    

Around plugin warnings

  • Always call $proceed(), unless you deliberately want to prevent execution (e.g. for access control). No $proceed() means all subsequent plugins AND the original method are skipped.
  • Performance: Every Around plugin generates a closure on the stack, more expensive than Before/After.
  • Maintainability: Harder to debug than Before/After, only use Around when Before+After aren't enough.
  • Prefer Before+After: The combination of Before and After plugins is almost always a better alternative to Around.

8. Plugin ordering and conflicts

Multiple plugins on the same method are called according to sortOrder:


<!-- Module A: sortOrder="10" -->
<type name="Magento\Catalog\Api\ProductRepositoryInterface">
    <plugin name="module_a_product" type="ModuleA\Plugin\ProductPlugin" sortOrder="10"/>
</type>

<!-- Module B: sortOrder="20" -->
<type name="Magento\Catalog\Api\ProductRepositoryInterface">
    <plugin name="module_b_product" type="ModuleB\Plugin\ProductPlugin" sortOrder="20"/>
</type>
    

Execution order for save() with Before, Around, After:

1. ModuleA::beforeSave()   (sortOrder=10)
2. ModuleB::beforeSave()   (sortOrder=20)
3. ModuleA::aroundSave($proceed) {  (sortOrder=10, wraps everything after it)
     4. ModuleB::aroundSave($proceed) {  (sortOrder=20)
          5. ORIGINAL save()
        }
   }
6. ModuleB::afterSave()    (sortOrder=20, After: reverse order)
7. ModuleA::afterSave()    (sortOrder=10)

After plugins run in REVERSE sortOrder order!
    

Plugins with equal priority: order follows module sequence in app/etc/config.php.

9. Plugin limits: what plugins can't do

Plugins have technical limitations:


<?php

// CANNOT be intercepted:
// 1. Final classes (final class)
final class CannotBePlugged
{
    public function doSomething(): void {} // No plugin possible
}

// 2. Final methods
class PartiallyPluggable
{
    final public function finalMethod(): void {} // No plugin possible
    public function pluggableMethod(): void {}   // Plugin possible
}

// 3. Static methods
class WithStaticMethods
{
    public static function staticMethod(): void {} // No plugin possible
}

// 4. Non-public methods
class WithPrivateMethods
{
    private function privateMethod(): void {}    // No plugin possible
    protected function protectedMethod(): void {} // No plugin possible
}

// 5. __construct
// Plugins on constructors are NOT possible
// → Use ObjectManager\ConfigInterface or Virtual Types instead

// 6. Classes without DI (direct new instantiation)
$obj = new SomeClass(); // Bypasses the DI container, no plugins possible
    

If a class is final and you still need to extend it:


<!-- Preference as a LAST RESORT for final classes -->
<!-- But: consider first whether you really need to override the final class -->
<config>
    <preference for="Some\Final\Class" type="Your\Override\Class"/>
</config>
    

10. Decision matrix: Preference vs. Plugin vs. Observer

The decision rule in short:


Do you want to change some behavior?
    │
    ├─ Can I solve it with an event/observer?
    │       └─ YES → Use an Observer (least invasive)
    │
    ├─ Is it about a public method of a non-final class?
    │       └─ YES → Plugin (Before/After/Around)
    │               ├─ Only change arguments? → Before plugin
    │               ├─ Only change the return value? → After plugin
    │               ├─ Both, or skip the method entirely? → Around plugin
    │               └─ Skip it altogether? → Around without $proceed() (be careful!)
    │
    ├─ Is the class final OR a constructor OR private/static?
    │       └─ Preference as a last resort
    │          Or: your own interface implementation via Preference
    │
    └─ Do you want to bind an interface to an implementation?
            └─ Preference (that's the correct use case!)
    
Use Case Recommendation Why
Bind interface → class Preference The only option, no conflict risk
Validate/modify method arguments Before plugin Simplest solution, easy to understand
Enrich/modify the return value After plugin Clear, testable, no risks
Add a caching layer Around plugin Needs pre- and post-execution access
Access control / authorization Around plugin Must be able to prevent the original call
Side effect after an event (email, log) Observer Decoupled, no direct interception needed
Overriding a final class Preference (last resort) No other option, document the conflict risk

Summary

Basic Rule
Plugin before Preference, plugins scale with other modules, Preferences don't
Plugin Types
Before: change arguments · After: change return value · Around: full control (use sparingly)
Limits
Plugins don't work on: final classes/methods, static methods, private/protected, constructors
Preference OK when
Binding interface to concrete class, or overriding a final class when there's no other option

Implementing clean Magento extensions

Code review for existing Preferences, plugin implementation, conflict analysis between modules.

????
Preference Audit
Analyze existing Preferences and replace them with plugins
????
Plugin Implementation
Develop Before/After/Around plugins for your use cases
⚔️
Conflict Analysis
Identify module conflicts caused by overlapping Preferences

Frequently Asked Questions About Preferences and Plugins in Magento

What is the difference between a Preference and a Plugin? +
A Preference fully replaces a class, only one can be active. A Plugin inserts code before/after/around a method without replacing the class, multiple plugins can coexist on the same method.
Why should I avoid Preferences? +
If two modules override the same class, only one wins, the other loses all its changes. They also couple you to the parent class, which can change with Magento updates.
When is a Preference acceptable? +
For interface-to-implementation binding, for final classes that cannot be extended via plugin, or when you register your own implementation of an interface (not overriding Magento core).
What does a Before plugin do? +
Runs before the original method and can modify the input arguments. Method name: before + MethodName. Returns an array with new arguments, or null for unchanged.
What does an After plugin do? +
Runs after the original method and can modify the return value. Method name: after + MethodName. Must return the (modified) return value.
When should I use an Around plugin? +
Sparingly, when you need to change both arguments and the return value, or must prevent the original method under certain circumstances. Always call $proceed()!
What happens if $proceed() is not called? +
The original method AND all subsequent plugins are skipped. Sometimes intentional (access denied), but usually a mistake. Always document it explicitly when omitted on purpose.
On which methods can plugins not be registered? +
No plugins on: final classes, final methods, static methods, private/protected methods, constructors, and objects created outside the DI container (new).
How does sortOrder work with multiple plugins? +
Before: ascending sortOrder (10, 20...). Around: wrap around each other ascending. After: reverse sortOrder (30, 20...). With equal sortOrder, module sequence decides.
Can I register a plugin on an interface? +
Yes, and it's recommended. Registering a plugin on the interface instead of the class is more flexible, it applies regardless of the concrete implementation. Use type='InterfaceName' instead of type='ClassName' in di.xml.