Inhalt
- 1. Zwei Wege, Magento zu erweitern
- 2. Preferences: Klasse ersetzen via di.xml
- 3. Warum Preferences problematisch sind
- 4. Plugins (Interceptoren): Das Interceptor Pattern
- 5. Before-Plugin: Argumente modifizieren
- 6. After-Plugin: Rückgabewert modifizieren
- 7. Around-Plugin: Vollständige Kontrolle (und Risiken)
- 8. Plugin-Sortierung und Konflikte
- 9. Plugin-Grenzen: Was Plugins nicht können
- 10. Entscheidungsmatrix: Preference vs. Plugin vs. Observer
Eine der häufigsten Fragen in Magento-Code-Reviews: "Soll ich eine Preference oder ein Plugin verwenden?" Die Antwort ist fast immer Plugin — aber warum, und mit welchem Typ? Dieser Deep Dive zeigt die genauen Unterschiede, die internen Mechanismen und die Situationen, in denen Preferences noch sinnvoll sind.
1. Zwei Wege, Magento zu erweitern
Magento bietet zwei primäre Wege, bestehenden Code zu modifizieren:
| Mechanismus | Wie es funktioniert | Skaliert mit anderen Modulen? |
|---|---|---|
| Preference | Ersetzt die gesamte Klasse durch eine eigene | ✗ Nein — Konflikte unvermeidlich |
| Plugin (Before/After/Around) | Fügt Code vor/nach/um eine Methode ein | ✓ Ja — mehrere Plugins auf derselben Methode |
| Observer | Reagiert auf Magento-Events | ✓ Ja — mehrere Observer pro Event |
| Event + Plugin kombiniert | Plugin dispatched eigenes Event für weitere Erweiterung | ✓ Ja — maximale Flexibilität |
2. Preferences: Klasse ersetzen via di.xml
Eine Preference teilt dem DI-Container mit: "Wenn jemand Klasse A anfordert, gib ihm stattdessen Klasse B":
<!-- app/code/Mironsoft/Catalog/etc/di.xml -->
<config>
<!-- PREFERENCE: Ersetzt ProductRepository vollständig -->
<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. Warum Preferences problematisch sind
Das Kernproblem: Nur eine Preference kann für eine Klasse aktiv sein. Bei zwei Modulen, die dasselbe überschreiben, gewinnt das zuletzt geladene:
Modul A: preference for="ProductRepository" type="A\ProductRepository"
Modul B: preference for="ProductRepository" type="B\ProductRepository"
→ Ergebnis: B\ProductRepository gewinnt
→ Alle Änderungen von A\ProductRepository sind verloren
→ Plugin von Modul A greift auf B\ProductRepository — funktioniert möglicherweise nicht
Preference-Risiken zusammengefasst
- Konflikt mit anderen Modulen: Wenn 2 Module dieselbe Klasse überschreiben, verliert eines
- Vererbungs-Kopplung: Wenn die Elternklasse Konstruktor-Argumente ändert, bricht deine Klasse
- Keine Komposabilität: Andere Module können deine Preference nicht "on top" erweitern
- Wartungsaufwand: Bei jedem Magento-Update muss geprüft werden, ob die Elternklasse sich geändert hat
Wenn Preferences noch sinnvoll sind:
- Interface-Binding:
<preference for="InterfaceA" type="ConcreteImpl"/>— das ist eigentlich kein Override, sondern Implementierung - Eigene Interface-Implementierung, nicht Core-Klassen überschreiben
- Wenn die Klasse final ist und Plugins nicht funktionieren (→ dann ist es ein Magento-Bug)
4. Plugins (Interceptoren): Das Interceptor Pattern
Plugins sind Magento's Implementierung des Interceptor Pattern (auch: Decorator Pattern). Der DI-Container generiert automatisch eine Interceptor-Klasse:
Deine Klasse: ProductRepository
Magento generiert: ProductRepository\Interceptor (in generated/)
Diese Interceptor-Klasse überschreibt ALLE public Methoden
und ruft registrierte Plugins in der richtigen Reihenfolge auf:
Plugin-Aufruf-Stack für 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
Plugin in di.xml registrieren:
<!-- 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: Argumente modifizieren
Before-Plugins können die Eingabe-Argumente einer Methode modifizieren, bevor sie ausgeführt wird:
<?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];
}
}
Wichtige Regeln für Before-Plugins:
- Methodenname:
before+ PascalCase der Originalmethode - Erster Parameter ist immer
$subject(das Original-Objekt) - Danach alle Parameter der Originalmethode
- Rückgabe:
arraymit neuen Argumenten odernullfür unverändert - Kein Rückgabewert-Zugriff — nur Argumente modifizierbar
6. After-Plugin: Rückgabewert modifizieren
After-Plugins können das Ergebnis einer Methode modifizieren, nachdem sie ausgeführt wurde:
<?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);
}
}
Wichtige Regeln für After-Plugins:
- Methodenname:
after+ PascalCase der Originalmethode - Erster Parameter:
$subject, zweiter:$result(Rückgabewert) - Original-Argumente danach (alle optional)
- Muss den (ggf. modifizierten) Rückgabewert zurückgeben
- Für void-Methoden: kein Rückgabewert nötig (return void)
7. Around-Plugin: Vollständige Kontrolle (und Risiken)
Around-Plugins wrappen die gesamte Methode — sie haben vollständige Kontrolle, tragen aber auch das höchste Risiko:
<?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 Warnungen
- Immer $proceed() aufrufen — außer wenn du die Ausführung bewusst verhinderst (z.B. für Access Control). Kein $proceed() → alle nachfolgenden Plugins UND die Originalmethode werden übersprungen.
- Performance: Jedes Around-Plugin generiert eine Closure im Stack — teurer als Before/After.
- Wartbarkeit: Schwerer zu debuggen als Before/After — benutze Around nur wenn Before+After nicht ausreichen.
- Prefer Before+After: Die Kombination aus Before- und After-Plugin ist fast immer eine bessere Alternative zu Around.
8. Plugin-Sortierung und Konflikte
Mehrere Plugins auf derselben Methode werden nach sortOrder aufgerufen:
<!-- Modul A: sortOrder="10" -->
<type name="Magento\Catalog\Api\ProductRepositoryInterface">
<plugin name="module_a_product" type="ModuleA\Plugin\ProductPlugin" sortOrder="10"/>
</type>
<!-- Modul B: sortOrder="20" -->
<type name="Magento\Catalog\Api\ProductRepositoryInterface">
<plugin name="module_b_product" type="ModuleB\Plugin\ProductPlugin" sortOrder="20"/>
</type>
Ausführungsreihenfolge für save() mit Before, Around, After:
1. ModuleA::beforeSave() (sortOrder=10)
2. ModuleB::beforeSave() (sortOrder=20)
3. ModuleA::aroundSave($proceed) { (sortOrder=10 — umschließt alles danach)
4. ModuleB::aroundSave($proceed) { (sortOrder=20)
5. ORIGINAL save()
}
}
6. ModuleB::afterSave() (sortOrder=20 — After: umgekehrte Reihenfolge)
7. ModuleA::afterSave() (sortOrder=10)
After-Plugins werden in UMGEKEHRTER sortOrder-Reihenfolge ausgeführt!
Plugin mit gleicher Priorität — Reihenfolge nach Modulreihenfolge in app/etc/config.php.
9. Plugin-Grenzen: Was Plugins nicht können
Plugins haben technische Einschränkungen:
<?php
// NICHT interceptierbar:
// 1. Finale Klassen (final class)
final class CannotBePlugged
{
public function doSomething(): void {} // Kein Plugin möglich
}
// 2. Finale Methoden
class PartiallyPluggable
{
final public function finalMethod(): void {} // Kein Plugin möglich
public function pluggableMethod(): void {} // Plugin möglich
}
// 3. Statische Methoden
class WithStaticMethods
{
public static function staticMethod(): void {} // Kein Plugin möglich
}
// 4. Nicht-öffentliche Methoden
class WithPrivateMethods
{
private function privateMethod(): void {} // Kein Plugin möglich
protected function protectedMethod(): void {} // Kein Plugin möglich
}
// 5. __construct
// Plugins auf Konstruktoren sind NICHT möglich
// → Verwende stattdessen ObjectManager\ConfigInterface oder Virtual Types
// 6. Klassen ohne DI (direkte new-Instantiierung)
$obj = new SomeClass(); // DI-Container umgangen → keine Plugins
Wenn eine Klasse final ist und du sie trotzdem erweitern musst:
<!-- Preference als LETZTER Ausweg bei finalen Klassen -->
<!-- Aber: Erwäge zuerst, ob du wirklich die finale Klasse überschreiben musst -->
<config>
<preference for="Some\Final\Class" type="Your\Override\Class"/>
</config>
10. Entscheidungsmatrix: Preference vs. Plugin vs. Observer
Die Entscheidungsregel in Kurzform:
Willst du ein Verhalten ändern?
│
├─ Kann ich es mit einem Event/Observer lösen?
│ └─ JA → Observer verwenden (am wenigsten invasiv)
│
├─ Geht es um eine öffentliche Methode einer nicht-finalen Klasse?
│ └─ JA → Plugin (Before/After/Around)
│ ├─ Nur Argumente ändern? → Before-Plugin
│ ├─ Nur Rückgabe ändern? → After-Plugin
│ ├─ Beides oder Methode überspringen? → Around-Plugin
│ └─ Gar nichts? → Around ohne $proceed() (Vorsicht!)
│
├─ Ist die Klasse final ODER ein Konstruktor ODER private/static?
│ └─ Preference als letzter Ausweg
│ Oder: Eigene Interface-Implementierung per Preference
│
└─ Willst du ein Interface an eine Implementierung binden?
└─ Preference (das ist die richtige Verwendung!)
| Use Case | Empfehlung | Warum |
|---|---|---|
| Interface → Klasse binden | Preference | Einzige Möglichkeit, kein Konflikt-Risiko |
| Methoden-Argumente validieren/modifizieren | Before-Plugin | Einfachste Lösung, klar verständlich |
| Rückgabewert anreichern/modifizieren | After-Plugin | Klar, testbar, keine Risiken |
| Caching-Layer hinzufügen | Around-Plugin | Braucht Pre- und Post-Zugriff |
| Zugriffskontrolle / Berechtigung | Around-Plugin | Muss Original verhindern können |
| Side-Effect nach Ereignis (E-Mail, Log) | Observer | Entkoppelt, kein Eingriff nötig |
| Finale Klasse überschreiben | Preference (letzter Ausweg) | Keine andere Option — Konflikt-Risiko dokumentieren |
Zusammenfassung
Magento-Erweiterungen sauber implementieren
Code-Review für vorhandene Preferences, Plugin-Implementierung, Konflikt-Analyse zwischen Modulen.