EXTEND
DI
Deep Dive · Magento 2 Erweiterbarkeit

Preferences vs. Plugins:
Wann welche Erweiterungsstrategie?

Wie Interceptoren intern funktionieren, warum Preferences meist die falsche Wahl sind, und die genauen Regeln für Before, After und Around Plugins.

15 min Lesezeit
Magento 2.4.8 · PHP 8.4
Magento Extension Points

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: array mit neuen Argumenten oder null fü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

Grundregel
Plugin vor Preference — Plugins skalieren mit anderen Modulen, Preferences nicht
Plugin-Typen
Before: Argumente ändern · After: Rückgabe ändern · Around: Vollständige Kontrolle (sparsam einsetzen)
Limits
Plugins funktionieren nicht auf: finale Klassen/Methoden, statische Methoden, private/protected, Konstruktoren
Preference OK wenn
Interface → konkrete Klasse binden, oder finale Klasse überschreiben wenn keine andere Option besteht

Magento-Erweiterungen sauber implementieren

Code-Review für vorhandene Preferences, Plugin-Implementierung, Konflikt-Analyse zwischen Modulen.

????
Preference-Audit
Bestehende Preferences analysieren und durch Plugins ersetzen
????
Plugin-Implementierung
Before/After/Around-Plugins für deine Use Cases entwickeln
⚔️
Konflikt-Analyse
Modul-Konflikte durch Preference-Überschneidungen identifizieren

Häufige Fragen zu Preferences und Plugins in Magento

Was ist der Unterschied zwischen Preference und Plugin? +
Eine Preference ersetzt eine Klasse vollständig — nur eine kann aktiv sein. Ein Plugin fügt Code vor/nach/um eine Methode ein ohne die Klasse zu ersetzen — mehrere Plugins können auf derselben Methode koexistieren.
Warum sollte ich Preferences vermeiden? +
Wenn zwei Module dieselbe Klasse überschreiben, gewinnt nur eines — das andere verliert alle Änderungen. Außerdem koppeln sie dich an die Elternklasse, die sich bei Magento-Updates ändern kann.
Wann ist eine Preference akzeptabel? +
Für Interface-zu-Implementierung-Binding, für finale Klassen die nicht per Plugin erweiterbar sind, oder wenn du eine eigene Implementierung eines Interfaces registrierst (nicht Magento-Core überschreibst).
Was macht ein Before-Plugin? +
Wird vor der Originalmethode ausgeführt und kann die Eingabe-Argumente modifizieren. Methodenname: before + MethodName. Gibt Array mit neuen Argumenten zurück oder null für unverändert.
Was macht ein After-Plugin? +
Wird nach der Originalmethode ausgeführt und kann den Rückgabewert modifizieren. Methodenname: after + MethodName. Muss den (modifizierten) Rückgabewert zurückgeben.
Wann sollte ich ein Around-Plugin verwenden? +
Sparsam — wenn du Argumente UND Rückgabe ändern musst, oder die Originalmethode unter bestimmten Umständen verhindern musst. Immer $proceed() aufrufen!
Was passiert wenn $proceed() nicht aufgerufen wird? +
Originalmethode UND alle nachfolgenden Plugins werden übersprungen. Manchmal gewünscht (Access Denied), meist aber ein Fehler. Immer explizit dokumentieren wenn bewusst weggelassen.
Auf welchen Methoden können keine Plugins registriert werden? +
Keine Plugins auf: finale Klassen, finale Methoden, statische Methoden, private/protected Methoden, Konstruktoren, und Objekte ohne DI-Container (new).
Wie funktioniert die sortOrder bei mehreren Plugins? +
Before: aufsteigende sortOrder (10, 20...). Around: wrappen umeinander aufsteigend. After: umgekehrte sortOrder (30, 20...). Bei gleicher sortOrder entscheidet Modulreihenfolge.
Kann ich ein Plugin auf einem Interface registrieren? +
Ja, und das ist empfohlen. Plugin auf Interface statt Klasse ist flexibler — greift unabhängig von der konkreten Implementierung. type='InterfaceName' statt type='ClassName' in di.xml verwenden.