Logo run_as_root - Magento B2B Agentur Würzburg

Preference vs Plugin in Magento 2: wenn Klassen-Rewrites zur Belastung werden

Magento 2s <preference>-Direktive ersetzt Klassen global. Wann es das richtige Werkzeug ist, wann eine Belastung, und wie du deine Codebase auf beides prüfst.


On this page

    Du erbst eine Magento-2-Codebase. Du willst wissen, was du da vor dir hast, und fährst das eine Kommando, das immer die Wahrheit sagt: find app/code -name di.xml | xargs grep -l '<preference'. Die Ausgabe ist eine Liste von dutzenden Dateien, und ein einzelnes Modul in der Codebase hat seine eigene Liste von über zwanzig Preferences. Du hörst nach dem dritten Screen auf zu lesen. Nichts davon stand im README.

    <preference> selbst ist nicht der Schurke. An den richtigen Stellen ist es das richtige Werkzeug. Der Schurke ist, <preference> als Reflex für „hier Verhalten ändern" zu nutzen, wenn ein Plugin oder ein Event-Observer die Aufgabe ohne jeden Preis erledigt hätte. Wenn wir das sehen, hat sich der Preis über drei Jahre und sieben Entwickler aufgezinst, und niemand weiß mehr, welche Preferences tragend sind und welche Experimente waren, die einfach ausgeliefert wurden.

    TL;DR

    • Die richtige Hierarchie lautet: Observer zuerst, Plugin zweite Wahl, Preference als letztes Mittel. Observer sind die Ereignis-Hooks, die das Framework vorgesehen hat. Plugins sind ein komponierbarer Workaround für Verhaltensänderungen. Preferences sind ein globaler Klassen-Tausch und sollten selten und begründet sein.
    • Ein <preference> in di.xml ersetzt eine Klasse global. Jedes new $class in der Codebase bekommt deine Ersetzung, überall, ob der Caller damit gerechnet hat oder nicht.
    • Ein Plugin auf derselben Klasse wrappt Methoden. Es komponiert mit anderen Plugins, überlebt Upstream-Signature-Änderungen besser und verhindert nicht, dass andere die Klasse erweitern.
    • Preference ist die richtige Wahl für abstrakte Factories, Adapter hinter einem Interface und einen schmalen Satz Service-Contract-Implementierungen. Es ist die falsche Wahl für Core-Models, HTTP-Controller, Blocks und fast alles unter Magento\Framework\.
    • Die vier Fehlermodi, die wir immer wieder sehen: zwei Module überschreiben dieselbe Klasse (einer gewinnt still), Upgrade-Fäule, wenn die Core-Method-Signatur sich ändert, Security-Plugins durch No-op-Stubs ersetzt, und Child-Klassen, die parent::__construct() auskommentiert haben, um einem Argument-Wechsel auszuweichen.
    • Der Audit ist billig. Ein Grep, ein Nachmittag Lesen, eine Tabelle mit „safe / risky / incident" pro Treffer. Das ist die Arbeit.

    Der Mechanismus: Observer, Plugin und <preference> auf DI-Ebene

    Magento bietet drei Erweiterungspunkte, und sie sind nicht gleichwertig.

    Ein Event-Observer (events.xml + Observer-Klasse) feuert, wenn das Framework ein benanntes Event dispatcht. Er ist der vorgesehene Mechanismus, um auf Dinge zu reagieren, die passiert sind: Bestellung aufgegeben, Produkt gespeichert, Kunde eingeloggt. Observer verändern keine Rückgabewerte, wrapppen keine Konstruktoren und streiten mit nichts. Sie sind der Extension-Point mit dem geringsten Reibungswiderstand und sollten deine erste Wahl sein.

    Ein Plugin (in di.xml per <type>-Knoten deklariert) wrappt eine konkrete Methode einer Klasse, ohne die Klasse zu ersetzen. beforeSave-, aroundExecute- und afterGetName-Hooks komponieren sich um die Methode. Das Originalobjekt bleibt erhalten, der Originalkonstruktor läuft unverändert, und mehrere Plugins aus mehreren Modulen stapeln sich in einer definierten Sort-Order. Plugins sind das Mittel der Wahl, wenn du Methodeneingaben oder -ausgaben ändern musst und es kein Event gibt, das deinen Fall abdeckt.

    Eine <preference> sagt Magentos DI-Compiler: „immer wenn jemand den Object-Manager nach einer Instanz von Klasse A fragt, gib ihm stattdessen eine Instanz von Klasse B." Das ist eine globale, zur Compile-Zeit wirksame Klassen-Ersetzung. new \Magento\Catalog\Model\Product(...) innerhalb von Core-Code liefert weiterhin Ihre Product-Subklasse zurück, weil der Object-Manager die Preference aufgelöst hat. Greif hierzu zuletzt, und nur in den engen Fällen unten.

    Der Unterschied zwischen Plugins und Preferences zeigt sich an drei Stellen, die zählen:

    Komposition. Zwei Module können Plugins auf derselben Methode deklarieren und beide laufen. Zwei Module können keine Preferences auf derselben Klasse deklarieren, bei denen beide laufen. Einer gewinnt, still, basierend auf Modul-Ladereihenfolge.

    Upgrade-Oberfläche. Ein Plugin kümmert sich um die Signatur einer Methode. Eine Preference kümmert sich um die gesamte Klasse: Konstruktorargumente, Methodensignaturen, die Reihenfolge privater Properties, die irgendwo via Reflection angesprochen werden. Wenn Magento 2.4.7 ein Konstruktorargument auf einem Core-Model ändert, bricht oder, schlimmer, hinkt jede Preference auf diesem Model.

    Observability. Ein Plugin taucht in Stacktraces unter seinem eigenen Klassennamen auf. Eine Preference ersetzt die Klasse komplett; ein Stacktrace zeigt Ihre Klasse, aber wenn Sie keinen Marker gesetzt haben, finden Code-Suchtools den Override nur, wenn sie di.xml crawlen.

    Wann <preference> die richtige Wahl ist

    Das Framework selbst liefert Preferences. Die Intention ist nicht „nie benutzen". Die Intention ist „dort benutzen, wo es der designte Erweiterungspunkt ist".

    Zwei Szenarien, in denen Preference die richtige Wahl ist:

    • Abstrakte Factories und Interfaces ohne Default. Magento\Framework\Filesystem\DriverInterface wird mit File als Default und einer Preference ausgeliefert, die das pinnt. Das ist der Core-DI-Mechanismus, wie er designt ist.
    • Adapter-Klassen hinter einem Service-Contract. Wenn du den Search-Client-Adapter oder den Messaging-Queue-Driver durch eine eigene Implementierung ersetzt, ist Preference die idiomatische Wahl, weil das Interface der Vertrag ist und der Adapter ein Implementierungsdetail.

    Wenn die Klasse, die du preferencen willst, konkret ist, greif stattdessen zu Observer oder Plugin. Wenn keins davon sauber passt, ist das ein Signal, dass das Design neu gedacht werden muss, nicht ein Signal, zur Preference zu greifen.

    Fehlermodus 1: zwei Module, ein Gewinner, stiller Datenverlust

    Zwei Marketplace-Module deklarieren beide ein <preference> für Magento\Catalog\Model\Product. Modul A schreibt die Klasse um, um SKU-Groß-/Kleinschreibung beim Save zu normalisieren. Modul B schreibt die Klasse um, um einen Custom-Attribute-Observer zu ergänzen. Der DI-Compiler löst eine der beiden auf, basierend auf Modul-Ladereihenfolge. Die Logik des anderen Moduls ist still unerreichbar. Keine Warnung zur Compile-Zeit. Keine Log-Zeile zur Laufzeit. Nur ein Feature, das funktioniert hat und nicht mehr funktioniert.

    Wir haben das zwischen zwei Inhouse-Modulen gesehen, zwischen Inhouse-Code und einer Marketplace-Extension, und zwischen zwei Marketplace-Extensions. Das Symptom ist immer dasselbe: „hat in Staging funktioniert, ist in Production gebrochen" nach einem Deploy, der die Modul-Ladereihenfolge verändert hat. Oder „läuft auf Dev, läuft nicht auf Prod", weil die composer.lock anders aufgelöst hat.

    ⚠️
    Es gibt kein <preference>-Äquivalent zu Plugin-sortOrder. Du kannst zwei konkurrierende Preferences nicht per Konfiguration auflösen. Der Fix ist immer, eine davon herauszureißen, üblicherweise durch Umbau zu einem Plugin oder Event-Observer. Weitere Preferences dagegenzustellen ist der Weg, auf dem Shops mit Dutzenden Preferences in einer Codebase enden.

    Fehlermodus 2: Upgrade-Fäule

    Preferences sind eng an die Signaturen der umgeschriebenen Klasse gekoppelt: Konstruktorargumente, öffentliche Methodensignaturen, manchmal protected Methods, die die Child-Klasse überschreibt. Wenn Magento eine neue Minor-Version ausliefert, die ein pflichtiges Konstruktorargument auf einer Core-Klasse ergänzt, muss jede Preference auf dieser Klasse aktualisiert werden, sonst scheitert die DI-Kompilierung.

    Das „oder hinkt"-Ergebnis ist häufiger als das „oder scheitert"-Ergebnis. Entwickler patchen den direkten Compile-Fehler, indem sie das fehlende Argument ergänzen, oft ohne zu lesen, wofür es da ist, oder es korrekt durchzuverdrahten. Die Klasse kompiliert und läuft. Das Verhalten, das das neue Konstruktorargument ermöglichen sollte, ist still abgeschaltet, weil die Preference-Implementierung es nicht nutzt.

    Deshalb bekommen preference-lastige Codebases mit den Jahren eine eigene Textur. Kleine Stücke Core-Verhalten hören nach jedem Upgrade auf zu funktionieren, und niemand merkt es, weil jedes einzelne klein ist. Die kumulative Wirkung ist ein Shop, auf dem die Hälfte der Plattform-Features still tot ist.

    Fehlermodus 3: Security-Plugins still ersetzt

    Die schlimmere Variante von Fehlermodus 1. Statt zweier Business-Logik-Module, die sich um Product streiten, deklariert ein Custom-Modul eine Preference auf ein Magento-Security-Plugin und ersetzt es durch ein No-op.

    Das kanonische Beispiel: Magento\ReCaptchaWebapiRest\Plugin\RestValidationPlugin. Eine Preference, die auf eine Custom-Klasse zeigt, die ohne Arbeit zurückgibt, schaltet reCAPTCHA-Validation auf jedem REST-fronted Login-Flow ab. Das Admin-UI zeigt reCAPTCHA weiter als aktiviert. Die Config liest sich gesund. Das Plugin wird zur Laufzeit umgangen, still, und kein Health-Check meldet es.

    Wir haben genau dieses Finding in Die 7 Security-Findings, die wir in fast jedem Magento-2-Code-Audit sehen unter Finding #6 behandelt. Das breitere Prinzip: jede <preference>, die eine Magento\*\Plugin\*-Klasse nimmt, ist ein Warnsignal. Plugins existieren, weil das Framework dieses konkrete Verhalten komponierbar haben will. Ein Plugin durch eine Preference zu ersetzen heißt „Komposition ist mir egal, ich will dieses Verhalten vollständig besitzen", was manchmal legitim ist und meistens nicht.

    Fehlermodus 4: gebrochene Konstruktorketten

    Das Muster: ein Modul-Autor schreibt eine Child-Klasse, die eine Magento-Framework-Klasse preferencen soll. Die Framework-Klasse hat viele Konstruktorargumente. Alle durchzuverdrahten ist langweilig. Der Autor überspringt entweder parent::__construct() komplett, ruft es mit einer Teilmenge der Argumente auf oder kommentiert es „vorübergehend" aus und liefert so aus.

    Wir haben das konkret auf Magento\Framework\Stdlib\Cookie\PhpCookieManager gesehen, auf TransportBuilder und auf genug Core-Collections, dass es kein Einzelfall ist. Jeder nächste Entwickler, der die Child-Klasse liest, sieht den auskommentierten Parent-Aufruf und fragt sich, ob das Absicht war. Niemand will der sein, der ihn einkommentiert, weil „könnte ja was kaputt machen". Er bleibt auskommentiert. Die Klasse funktioniert in den meisten Flows, weil die meisten Flows das, was parent::__construct initialisiert hätte, nicht berühren. Die Flows, die es berühren, brechen subtil, in Production, deutlich später.

    Der Fix ist immer derselbe: den parent::__construct()-Aufruf mit allen pflichtigen Argumenten wiederherstellen, Tests laufen lassen, die brechenden Tests fixen. Der Bug, den der auskommentierte Aufruf umgangen hat, ist meistens ein Konstruktorargument, das in einem Magento-Release ergänzt wurde und nicht dem entspricht, was die Child-Klasse übergeben hat. Das Signaturmismatch lösen, nicht das Symptom.

    Die Preferences in deiner Codebase auditieren

    Das eine Kommando fahren. Jeden Treffer klassifizieren. Das ist der Audit.

    1find app/code -name di.xml | xargs grep -l '<preference' | while read f; do echo "--- $f ---"; grep -oE '<preference[^>]+for="[^"]+"[^>]+type="[^"]+"' "$f"; done

    Die Ausgabe in eine Tabelle kopieren. Drei Spalten: Modul, Zielklasse, dein Urteil. Urteile sind eins von:

    • SAFE. Ziel ist ein Interface ohne Default, ein Adapter hinter einem Service-Contract, eine Factory. Die Preference tut das idiomatische Ding.
    • RISKY. Ziel ist eine konkrete Core-Klasse. Die Preference mag heute funktionieren; sie ist eine Wartungslast für jedes zukünftige Upgrade. Kandidat für ein Plugin-Refactor.
    • INCIDENT. Ziel ist eine Plugin-Klasse, besonders ein Security-Plugin. Ziel ist eine Magento\Framework\*-Klasse. Oder die Child-Klasse enthält ein auskommentiertes parent::__construct(). Heute lesen, heute fixen.

    Eine Codebase mit dreißig oder mehr Preferences hat eine Handvoll von jeder Sorte. Die INCIDENT-Zeilen fliegen raus. Die RISKY-Zeilen gehen ins Refactor-Backlog mit einer Plugin-Umbau-Schätzung. Die SAFE-Zeilen bleiben, wie sie sind, und werden dokumentiert, damit der nächste Entwickler weiß, warum sie da sind.

    ⚠️
    Lösch Preferences nicht pauschal. Auch die schlechten sind tragend, bis jemand das Gegenteil beweist. Der Migrationspfad ist immer: einen Plugin oder Observer schreiben, der dasselbe tut, ihn parallel zur <preference> deployen, verifizieren, dass der neue Code zuerst läuft, dann die Preference löschen. Die Preference zu entfernen, bevor der Ersatz steht, ist der Weg, eine Regression auszuliefern.

    Weiterführende Artikel

    Die Preference-Audit-Checkliste

    1. 01

      Jede Preference in der Codebase aufzählen

      Ausführen: find app/code -name di.xml | xargs grep -l '<preference' und die for/type-Paare extrahieren. vendor/ ist für den ersten Durchgang meist außer Scope, aber einen zweiten Grep wert, wenn du ein Vendor-Modul als Verursacher vermutest.

    2. 02

      Jede als SAFE, RISKY oder INCIDENT klassifizieren

      Interface ohne Default oder Adapter hinter einem Service-Contract = SAFE. Konkrete Core-Klasse = RISKY. Plugin-Klasse oder Magento\Framework\*-Ziel = INCIDENT. Auskommentiertes parent::__construct() in der Child-Klasse = INCIDENT, unabhängig vom Ziel.

    3. 03

      Nach konkurrierenden Preferences auf derselben Klasse prüfen

      Die extrahierte Zielklassen-Spalte auf Duplikate greppen. Jedes Ziel mit mehr als einer Preference-Deklaration heißt, dass ein Modul still verliert. Fixen, bevor Sie irgendetwas anderes ausliefern.

    4. 04

      INCIDENT-Zeilen zuerst triagieren

      Die Child-Klasse lesen. Wenn sie parent::__construct überspringt, wiederherstellen und die Test-Suite laufen lassen. Wenn sie ein Security-Plugin nimmt, verifizieren, dass die Ersetzung das Richtige tut, oder zu einem sauberen Plugin umbauen.

    5. 05

      Die RISKY-Umbauten planen

      Für jede RISKY-Zeile den Plugin-Refactor schätzen. Die meisten konkrete-Core-Klasse-Preferences lassen sich als ein oder zwei Plugins (beforeSave / afterLoad / aroundExecute) abbilden, mit einem Bruchteil des Upgrade-Risikos. Priorisierung danach, wie oft die Klasse auf heißen Pfaden liegt.

    6. 06

      Die SAFE-Zeilen dokumentieren

      Einen Kommentar in der di.xml ergänzen, der erklärt, warum jede SAFE-Preference die idiomatische Wahl ist. Ihr zukünftiges Ich oder der nächste Entwickler wird sich in 18 Monaten dafür bedanken, wenn die Frage wiederkommt.

    7. 07

      Einen CI-Guard gegen neue Preferences ergänzen

      Ein einfacher Check, der den Build scheitern lässt, wenn eine neue <preference> in einem PR auftaucht, ohne ein verlinktes Issue, das die SAFE-Klassifizierung erklärt. Verhindert, dass der Bestand nachwächst.

    8. 08

      Nach jedem Magento-Minor-Release neu auditieren

      Jedes Magento-Upgrade ändert Konstruktorsignaturen auf einigen Core-Klassen. Die Aufzählung vor und nach dem Upgrade erneut fahren. Jede Preference, deren Zielklasse ein neues pflichtiges Argument bekommen hat, braucht Aufmerksamkeit.

    Dreißig Preferences in einer Codebase ist ein Refactor-Projekt

    Wir machen Magento-2-Code-Audits und Custom-Development-Engagements, die einen Preference-Bestand in einen Plugin-Migrationsplan verwandeln. Du bekommst die Tabelle, die Umbau-Schätzungen und die PRs. Keine 80-seitige PDF.

    Über einen Refactor sprechen