Logo run_as_root - Magento B2B Agency Würzburg
GENERAL

Preference vs Plugin in Magento 2: When Class Rewrites Become a Liability

Magento 2's <preference> directive globally replaces classes. When it's the right tool, when it's a liability, and how to audit your codebase for both.


On this page

    You inherit a Magento 2 codebase. You want to know what you're dealing with, so you run the one command that always tells the truth: find app/code -name di.xml | xargs grep -l '<preference'. The output is a list of dozens of files, and one module in the codebase has its own list of twenty-plus preferences all on its own. You stop reading after the third screen. None of it was in the README.

    <preference> itself isn't the villain. In the right places, it's the right tool. The villain is using <preference> as a reflex for "change behaviour here" when a plugin or an event observer would have done the job with none of the cost. By the time we see it, the cost has compounded across three years and seven devs, and nobody remembers which preferences are load-bearing and which ones were experiments that shipped.

    TL;DR

    • The correct hierarchy is: observer first, plugin second, preference last. Observers are proper event hooks the framework was designed around. Plugins are a composable workaround for modifying behaviour. Preferences are a global class substitution that should be rare and justified.
    • A <preference> in di.xml globally replaces a class. Every new $class in the codebase gets your replacement, everywhere, whether the caller expected it or not.
    • A plugin on the same class wraps methods. It composes with other plugins, survives upstream signature changes better, and doesn't prevent anyone else from extending the class.
    • Preference is the correct choice for abstract factories, adapters behind an interface, and a narrow set of service-contract implementations. It's the wrong choice for core models, HTTP controllers, blocks, and almost anything under Magento\Framework\.
    • The four failure modes we see over and over: two modules overriding the same class (one wins silently), upgrade rot when the core method signature changes, security plugins replaced by no-op stubs, and child classes that commented out parent::__construct() to avoid an argument change.
    • The audit is cheap. One grep, one afternoon of reading, a spreadsheet with "safe / risky / incident" for each hit. That's the work.

    The mechanic: observer, plugin, and <preference> at the DI level

    Magento gives you three extension points, and they're not equals.

    An event observer (events.xml + observer class) fires when the framework dispatches a named event. It's the intended mechanism for reacting to things that happened: order placed, product saved, customer logged in. Observers don't modify method return values, don't wrap constructors, and don't fight with anything. They're the lowest-friction extension point and should be your first choice.

    A plugin (declared in di.xml via a <type> node) wraps a specific method on a class without replacing the class. beforeSave, aroundExecute, afterGetName hooks compose around the method. The original object is preserved, the original constructor runs unchanged, and multiple plugins from multiple modules stack in a defined sort order. Use plugins when you need to modify method inputs or outputs and there's no event that covers your case.

    A <preference> tells Magento's DI compiler: "whenever anyone asks the object manager for an instance of class A, give them an instance of class B instead." It's a global, compile-time class substitution. new \Magento\Catalog\Model\Product(...) inside core code still returns your Product subclass, because the object manager resolved the preference. Reach for this last, and only in the narrow cases below.

    The difference between plugins and preferences shows up in three places that matter:

    Composition. Two modules can declare plugins on the same method and both run. Two modules cannot declare preferences on the same class and both run. One wins, silently, based on module load order.

    Upgrade surface. A plugin cares about one method's signature. A preference cares about the entire class: constructor arguments, method signatures, the order of private properties accessed via reflection somewhere else. When Magento 2.4.7 changes a constructor argument on a core model, every preference on that model breaks or, worse, limps.

    Observability. A plugin shows up in stack traces by its own class name. A preference replaces the class entirely; a stack trace shows your class, but if you didn't add a marker, code search tools won't find the override unless they crawl di.xml.

    When <preference> is the right call

    The framework itself ships preferences. The intent isn't "never use this." The intent is "use it where it's the designed extension point."

    Two scenarios where preference is the correct choice:

    • Abstract factories and interfaces without a default. Magento\Framework\Filesystem\DriverInterface shipping with File as the default and a preference pinning it. This is the core DI mechanic working as designed.
    • Adapter classes behind a service contract. If you're replacing the search client adapter or the messaging-queue driver with a custom implementation, preference is the idiomatic choice because the interface is the contract and the adapter is an implementation detail.

    If the class you're tempted to <preference> is concrete, reach for an observer or plugin instead. If neither fits cleanly, that's a signal the design needs rethinking, not a signal to reach for preference.

    Failure mode 1: two modules, one winner, silent data loss

    Two Marketplace modules both declare a <preference> for Magento\Catalog\Model\Product. Module A rewrites the class to normalize SKU casing on save. Module B rewrites the class to add a custom attribute observer. The DI compiler resolves one of them, based on module load order. The other module's logic is silently unreachable. No warning at compile time. No log line at runtime. Just a feature that used to work and doesn't anymore.

    We've seen this happen between two in-house modules, between in-house code and a Marketplace extension, and between two Marketplace extensions. The symptom pattern is always the same: "this worked in staging, broke in production" after a deploy that changed module load order. Or "it works on dev, doesn't work on prod" because the composer lock file resolved differently.

    ⚠️
    There's no <preference> equivalent of plugin sortOrder. You can't resolve two competing preferences with configuration. The fix is always to rip one of them out, usually by converting it to a plugin or an event observer. Adding more preferences to fight back is how stores end up with dozens of preferences in one codebase.

    Failure mode 2: upgrade rot

    Preferences are tightly coupled to the rewritten class's signatures: constructor arguments, public method signatures, sometimes protected methods that the child class overrides. When Magento releases a new minor version that adds a required constructor argument to a core class, every preference on that class needs to be updated or it fails at DI compilation.

    The "or limps" outcome is more common than the "or fails" outcome. Devs patch the immediate compile error by adding the missing argument, often without reading what it's for or wiring it through correctly. The class compiles and runs. The behaviour that the new constructor argument was meant to enable is silently disabled because the preference implementation doesn't use it.

    This is why preference-heavy codebases acquire a distinct texture over the years. Small bits of core behaviour stop working after each upgrade, and nobody notices because each individual one is minor. The cumulative effect is a store where half the platform's features are quietly dead.

    Failure mode 3: security plugins quietly replaced

    The worst variant of failure mode 1. Instead of two business-logic modules fighting over Product, a custom module declares a preference on a Magento security plugin and replaces it with a no-op.

    The canonical example: Magento\ReCaptchaWebapiRest\Plugin\RestValidationPlugin. A preference pointing to a custom class that returns without doing anything turns off reCAPTCHA validation on every REST-fronted login flow. The admin UI still shows reCAPTCHA as enabled. The config reads healthy. The plugin is bypassed at runtime, silently, and no health check reports it.

    We've covered this specific finding in The 7 Security Findings We See in Almost Every Magento 2 Code Audit under finding #6. The broader principle: any <preference> that targets a Magento\*\Plugin\* class is a red flag. Plugins exist because the framework wants that specific behaviour to be composable. Replacing a plugin with a preference is saying "I don't care about composability, I want to own this behaviour outright", which is sometimes legitimate and usually isn't.

    Failure mode 4: broken constructor chains

    The pattern: a module author writes a child class meant to preference a Magento framework class. The framework class has a lot of constructor arguments. Plumbing all of them through is boring. The module author either skips parent::__construct() entirely, or calls it with only a subset of the arguments, or comments it out "temporarily" and ships.

    We've seen this on Magento\Framework\Stdlib\Cookie\PhpCookieManager specifically, and on TransportBuilder, and on enough core collections that it's not a one-off. Every subsequent dev who reads the child class sees the commented-out parent call and wonders if it was deliberate. Nobody wants to be the person who uncomments it, because "maybe it broke something". It stays commented out. The class works in most flows because most flows don't exercise whatever parent::__construct was initialising. The flows that do break subtly, in production, much later.

    The fix is always the same: restore the parent::__construct() call with all required arguments, run tests, fix the tests that break. The bug the commented-out call was working around is usually a constructor argument that got added in a Magento release that didn't match what the child class was passing. Solve the signature mismatch, not the symptom.

    Auditing the preferences already in your codebase

    Run the one command. Classify each hit. That's the audit.

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

    Paste the output into a spreadsheet. Three columns: module, target class, your verdict. Verdicts are one of:

    • SAFE. Target is an interface without a default, an adapter behind a contract, a factory. The preference is doing the idiomatic thing.
    • RISKY. Target is a concrete core class. The preference might be working fine today; it's a maintenance liability for every future upgrade. Candidate for a plugin refactor.
    • INCIDENT. Target is a plugin class, especially a security plugin. Target is a Magento\Framework\* class. Or the child class contains a commented-out parent::__construct(). Read the code today, fix today.

    A codebase with thirty-plus preferences will have a handful of each. The INCIDENT rows come out. The RISKY rows go into the refactor backlog with a plugin-conversion estimate. The SAFE rows stay as-is and get documented so the next dev knows why they're there.

    ⚠️
    Don't delete preferences wholesale. Even the bad ones are load-bearing until someone proves otherwise. The migration path is always: write a plugin or observer that does the same thing, deploy it alongside the <preference>, verify the new code runs first, then delete the preference. Removing the preference before the replacement is in place is how you ship a regression.

    The preference-audit checklist

    1. Enumerate every preference in the codebase

      Run find app/code -name di.xml | xargs grep -l '<preference' and extract the for/type pairs. Vendor/ is usually out of scope for the first pass, but worth a second grep if you suspect a vendor module is contributing.

    2. Classify each as SAFE, RISKY, or INCIDENT

      Interface without a default or adapter behind a service contract = SAFE. Concrete core class = RISKY. Plugin class or Magento\Framework\* target = INCIDENT. Commented-out parent::__construct() in the child class = INCIDENT regardless of the target.

    3. Check for competing preferences on the same class

      grep the extracted target-class column for duplicates. Any target with more than one preference declaration means one module is silently losing. Fix before shipping anything else.

    4. Triage INCIDENT rows first

      Read the child class. If it skips parent::__construct, restore it and run the test suite. If it targets a security plugin, verify the replacement does the right thing or convert it to a proper plugin.

    5. Plan the RISKY conversions

      For each RISKY row, estimate the plugin refactor. Most concrete-core-class preferences can become one or two plugins (beforeSave / afterLoad / aroundExecute) with a fraction of the upgrade risk. Prioritise by how often the class is called in hot paths.

    6. Document the SAFE rows

      Add a comment in the di.xml explaining why each SAFE preference is the idiomatic choice. Future-you, or the next dev, will thank you in 18 months when the question comes up again.

    7. Add a CI guard against new preferences

      A simple check that fails the build when a new <preference> appears in a PR without a linked issue explaining the SAFE classification. Prevents the inventory from growing back.

    8. Re-audit every Magento minor release

      Each Magento upgrade changes constructor signatures on some core classes. Rerun the enumeration before and after the upgrade. Any preference whose target class got a new required argument needs attention.

    Thirty preferences in one codebase is a refactor project

    We do Magento 2 code audits and custom-development engagements that turn a preference inventory into a plugin migration plan. You get the spreadsheet, the conversion estimates, and the PRs. No 80-page PDF.

    Talk to us about a refactor