Contents
- 1. Two ways to extend Magento
- 2. Preferences: replacing a class via di.xml
- 3. Why Preferences are problematic
- 4. Plugins (interceptors): the Interceptor Pattern
- 5. Before plugin: modifying arguments
- 6. After plugin: modifying the return value
- 7. Around plugin: full control (and risks)
- 8. Plugin ordering and conflicts
- 9. Plugin limits: what plugins can't do
- 10. Decision matrix: Preference vs. Plugin vs. Observer
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:
arraywith new arguments ornullfor 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
Implementing clean Magento extensions
Code review for existing Preferences, plugin implementation, conflict analysis between modules.