The Preference Pattern in Magento 2
AI generated
mironsoft.de › Blog › Design Patterns
Magento 2 · Design Patterns
The Preference Pattern
in Magento 2

Preferences in di.xml bind interfaces to concrete implementations, or override existing classes. This is the most powerful tool in the DI system, and also the one most often misused. Interface-first design, preference conflicts, and virtual types as an alternative.

15 min read PHP 8.4 Magento 2.4.8

What are preferences in Magento 2?

Preferences are DI configurations in the di.xml file that tell the Magento 2 ObjectManager which concrete PHP class it should instantiate when a given interface or class is requested. The term "preference" means exactly that: you tell the DI container which implementation it should "prefer" when it needs to resolve a particular dependency. Without preferences, the DI container could not instantiate interfaces, because PHP itself cannot instantiate interfaces.

There are two fundamental use cases for preferences. The first, and by far the more common, is interface binding, where an interface is connected to its concrete implementation. This use case is essential for any module that uses service contracts (interfaces). The second is class override, where an existing Magento class is replaced by a custom implementation. This use case is powerful but risky, and should be used with care.

The preference syntax in di.xml is about as simple as it gets: the <preference> element has two attributes: for (the interface or class being overridden) and type (the concrete class to use instead). The full class namespace must be given. Preferences in your own module's di.xml take priority over preferences in core modules, provided your module is loaded after the core module.

Preferences are part of the Magento 2 Dependency Injection container, which in turn is built on the Inversion of Control (IoC) principle. High-level modules (business logic) should not depend on low-level modules (concrete implementations); instead, both should depend on abstractions (interfaces). Preferences are the mechanism that makes this abstraction possible, by configuring the link between interface and implementation outside of the code itself.

Interface-first design: the foundation

Interface-first design is the architectural principle on which Magento 2 modules should be built. The idea is simple: before you write an implementation, you define the interface. The interface describes the "what": which operations are possible, which parameters are expected, which return types are promised. The implementation describes the "how". Other modules only ever know the interface, never the implementation.

In practice, interface-first design leads to the following structure for a Magento 2 module: the Api/ directory holds all interfaces. The Api/Data/ subdirectory contains data interfaces (such as PostInterface) that define typed getters and setters. The main Api/ directory contains repository and service interfaces (such as PostRepositoryInterface). The Model/ directory holds the concrete implementations of these interfaces. Finally, etc/di.xml establishes the connections via preferences.

<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Api\Data;

/**
 * Blog post data interface, defines the contract for post entities.
 * All other modules depend on this interface, never on the concrete model.
 */
interface PostInterface
{
    public const POST_ID = 'post_id';
    public const TITLE = 'title';
    public const CONTENT = 'content';
    public const URL_KEY = 'url_key';
    public const IS_ACTIVE = 'is_active';
    public const PUBLISH_DATE = 'publish_date';
    public const AUTHOR_ID = 'author_id';

    /**
     * Get post ID.
     */
    public function getId(): ?int;

    /**
     * Get post title.
     */
    public function getTitle(): string;

    /**
     * Set post title.
     */
    public function setTitle(string $title): static;

    /**
     * Get post content.
     */
    public function getContent(): string;

    /**
     * Set post content.
     */
    public function setContent(string $content): static;

    /**
     * Get URL key.
     */
    public function getUrlKey(): string;

    /**
     * Check if post is active.
     */
    public function isActive(): bool;

    /**
     * Set active status.
     */
    public function setIsActive(bool $isActive): static;

    /**
     * Get publish date.
     */
    public function getPublishDate(): string;
}

Interface-first design brings a far-reaching benefit: the implementation can be swapped out without other modules needing to change. If a better implementation of PostRepositoryInterface comes along tomorrow, say one that integrates a cache, it is enough to change the preference in di.xml. Every module that uses the interface automatically works with the new implementation, with no code changes required.

With PHP 8.4's strict types and the new property hook features, interfaces can be defined even more precisely. Return types are fully typed, as are parameters. PHPStan checks that the implementation matches the interface definition. If the implementation implements a method with a different return type, PHPStan raises an error, so interface violations are caught already at development time.

Interface-to-class binding: the correct use case

Interface-to-class binding is the correct and indispensable use of preferences. Every time an interface is injected into a class via constructor injection, the DI container needs a preference to know which concrete class to instantiate. Without that preference, the ObjectManager throws a LogicException or a comparable exception on the first call, saying the interface cannot be instantiated.

A complete module built around the repository pattern typically needs at least three preferences: the first binds the repository interface (PostRepositoryInterface) to the repository implementation (PostRepository). The second binds the data interface (PostInterface) to the model (Post). The third binds the SearchResults interface (PostSearchResultsInterface) to the SearchResults class (PostSearchResults).

<?xml version="1.0"?>
<!-- app/code/Mironsoft/Blog/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <!-- Repository Interface → Concrete Implementation -->
    <preference for="Mironsoft\Blog\Api\PostRepositoryInterface"
                type="Mironsoft\Blog\Model\PostRepository"/>

    <!-- Data Interface → Model (for type-safe entity handling) -->
    <preference for="Mironsoft\Blog\Api\Data\PostInterface"
                type="Mironsoft\Blog\Model\Post"/>

    <!-- SearchResults Interface → Concrete SearchResults -->
    <preference for="Mironsoft\Blog\Api\Data\PostSearchResultsInterface"
                type="Mironsoft\Blog\Model\PostSearchResults"/>

    <!-- Optional: Area-specific logger using Virtual Type -->
    <type name="Mironsoft\Blog\Model\PostRepository">
        <arguments>
            <argument name="logger" xsi:type="object">Mironsoft\Blog\Logger\Logger</argument>
        </arguments>
    </type>

</config>

One important detail: the preference configuration also applies to generated factories. If PostFactory is called and the preference for PostInterface is set to Post, the factory creates a Post object. This is consistent, and it means you can always work with the interface, even when using factories. The factory respects the preference configuration.

Class override via preference

The second use of preferences is the class override: an existing Magento class is replaced by a custom implementation. The new class must extend the original one (via PHP inheritance) and can override methods or add new ones. This is the only way to add new public methods to an existing class; plugins cannot do this, because they cannot add new methods to a class.

A typical example of a legitimate class override: you want to add a new method to the Magento customer model that cannot be expressed with a plugin. You create a custom class that extends Magento\Customer\Model\Customer and contains the new method. Then, in di.xml, you set a preference that replaces the core customer class with your own. Every place in the codebase that gets Magento\Customer\Model\Customer injected now automatically receives the extended version.

<?php
declare(strict_types=1);

namespace Mironsoft\CustomerExtension\Model;

use Magento\Customer\Model\Customer as CoreCustomer;

/**
 * Extended customer model, adds VIP tier management.
 * Used as preference for Magento\Customer\Model\Customer in di.xml.
 */
class Customer extends CoreCustomer
{
    /**
     * Check if customer has VIP status.
     * New method, not expressible via Plugin.
     */
    public function isVip(): bool
    {
        return (bool) $this->getData('is_vip');
    }

    /**
     * Get customer VIP tier level.
     * Returns: 'none', 'silver', 'gold', 'platinum'
     */
    public function getVipTier(): string
    {
        return (string) ($this->getData('vip_tier') ?? 'none');
    }

    /**
     * Set VIP tier.
     */
    public function setVipTier(string $tier): static
    {
        $this->setData('vip_tier', $tier);
        return $this;
    }
}

Class overrides, however, carry a significant risk: they break during Magento upgrades whenever the core class changes. If Magento adds a new method to the Customer class or changes the signature of an existing method, the custom class must be adjusted as well. That is maintenance-heavy. Plugins, by contrast, are considerably more upgrade-safe, because they do not depend on the full class signature.

Preferences vs. plugins: when to use what?

The choice between a preference and a plugin is one of the most important architectural decisions when building a Magento 2 module. The basic rule: preferences for interface bindings (always required) and for adding new methods (only when absolutely necessary). Plugins for modifying or extending existing methods (the normal case for core extensions).

Plugins are the more flexible and more upgrade-safe tool. A plugin can wrap the original method before (before), after (after), or around (around) it. Multiple plugins for the same method can coexist and are executed in the order of their sortOrder. That is the decisive advantage over preferences: if two modules extend the same method via plugin, both work at the same time. If two modules override the same class via preference, only one of them works.

<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Model\Product;
use Magento\Catalog\Api\Data\ProductInterface;

/**
 * Product plugin, extends product functionality without Preference.
 * Multiple plugins for the same class can coexist, unlike Preferences.
 */
class ProductPlugin
{
    /**
     * After getName, append custom badge to product name in listing.
     * This is safe: other modules can also have afterGetName plugins.
     */
    public function afterGetName(
        Product $subject,
        string $result
    ): string {
        $badge = (string) $subject->getData('custom_badge');

        if ($badge !== '') {
            return sprintf('%s [%s]', $result, $badge);
        }

        return $result;
    }

    /**
     * Before save, normalize custom badge before persisting.
     */
    public function beforeSave(Product $subject): void
    {
        $badge = (string) $subject->getData('custom_badge');
        $subject->setData('custom_badge', trim(strtolower($badge)));
    }
}

Around plugins offer the most powerful control: they wrap the original method call completely and can run code both before and after it, and can even skip the original call entirely. This should be used with care, though, because around plugins add overhead and can be hard to debug. For simple pre- or post-processing, before and after plugins are clearly preferable.

Preference conflicts between two modules

The biggest problem with class-override preferences is the conflict that arises when two modules override the same class. In that case, the preference from the module that loads last wins, determined by the sequence directive in module.xml. The other module's preference is silently ignored: its code simply does not run, with no error and no warning. This is one of the trickiest sources of bugs in multi-module Magento 2 projects.

A typical scenario: a third-party module A overrides Magento\Catalog\Model\Product with its own class. A second module B, from a different vendor, overrides the same class. The project uses both modules. Depending on load order, either A or B works, but never both. If module B loads last, module A's extension is silently never executed, even though the module is active.

The professional solution to this problem is a combination of chaining and plugins. Module B should not inherit from the core class, but from module A's class instead: class Product extends \ModulA\Catalog\Model\Product. That way, module B automatically inherits module A's extensions. Both modules work, and load order no longer matters, as long as module B loads after module A. The ideal solution, though, is to use plugins instead of preferences from the outset whenever method modification is the goal.

Magento 2.4.8 offers the bin/magento dev:di:info command as a diagnostic tool that shows the active preferences and plugins for a class. This command is indispensable when analyzing preference conflicts: it shows which preference is currently active and which plugins are registered on the class. In a project with many third-party modules, this command should be run whenever a new module is integrated.

Virtual type as an alternative to preferences

Virtual types are, in certain scenarios, an elegant alternative to class-override preferences. A virtual type creates a new "class" in the DI configuration that configures an existing class with different constructor arguments. Unlike a preference-based custom class, there is no PHP inheritance relationship involved: it is the same PHP class, just with a different DI configuration.

Virtual types can serve as preferences for interfaces. That means you can bind an interface not to the original class, but to a virtual type. The virtual type then has the same methods as the original class (because it is the same PHP class), but with a different configuration (different constructor arguments). This is particularly useful when you want the same interface implemented differently in different contexts.

<?xml version="1.0"?>
<!-- app/code/Mironsoft/Blog/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <!-- Virtual Type as a configured variant of a service -->
    <virtualType name="Mironsoft\Blog\Model\CachedPostRepository"
                 type="Mironsoft\Blog\Model\PostRepository">
        <arguments>
            <!-- Use cache-enabled resource model variant -->
            <argument name="cache" xsi:type="object">Magento\Framework\App\Cache\Type\Block</argument>
            <argument name="cacheTtl" xsi:type="number">3600</argument>
        </arguments>
    </virtualType>

    <!-- Use the cached variant for frontend, original for admin -->
    <!-- In etc/frontend/di.xml: -->
    <!-- <preference for="Mironsoft\Blog\Api\PostRepositoryInterface"
                    type="Mironsoft\Blog\Model\CachedPostRepository"/> -->

    <!-- In etc/di.xml (global, includes admin): -->
    <preference for="Mironsoft\Blog\Api\PostRepositoryInterface"
                type="Mironsoft\Blog\Model\PostRepository"/>

    <!-- Standard interface bindings -->
    <preference for="Mironsoft\Blog\Api\Data\PostInterface"
                type="Mironsoft\Blog\Model\Post"/>

    <preference for="Mironsoft\Blog\Api\Data\PostSearchResultsInterface"
                type="Mironsoft\Blog\Model\PostSearchResults"/>

</config>

Virtual types have no effect on other modules that extend the same class via plugin. Those plugins remain active, because the virtual type is the same PHP class. That is an advantage over a preference-based custom class, where plugins from other modules may fail to apply if the class hierarchy has changed. Virtual types are, in this respect, more cooperative in a multi-module environment.

Area-specific preferences

Magento 2 lets you configure preferences differently for different application areas. This means you can use a different implementation of an interface for the frontend area than for the admin area or the REST API. This flexibility is very useful in certain scenarios, for instance when you want a cached implementation for the frontend area and an uncached one for the admin area.

Configuration happens in area-specific di.xml files: etc/frontend/di.xml applies only to frontend requests, etc/adminhtml/di.xml only to admin requests, etc/webapi_rest/di.xml only to REST API requests, and etc/webapi_soap/di.xml to SOAP API requests. The global etc/di.xml applies to all areas but is overridden by area-specific configurations whenever the request is handled in a given area.

Area-specific preferences are especially relevant for performance optimization. Caching layers that make sense on the frontend can get in the way in the admin, because admin operations always need up-to-date data. An area-specific preference pointing the frontend to a cached repository implementation, while the global preference points to the uncached one, solves this problem elegantly and without complex runtime conditions in the code.

After every preference change, whether global or area-specific, the DI container must be recompiled. bin/magento setup:di:compile reads all di.xml files, resolves preferences, generates interceptors for plugins, and writes the resulting configuration into the generated/ folder. Without this step, a preference change is not active in a production environment. In developer mode, Magento compiles automatically on demand, but for production the explicit compile step is essential.

Summary: preferences in Magento 2

Preferences are essential for interface bindings. For class overrides, only when new methods need to be added. For method modification, always prefer plugins: they can be combined without conflicts. Preference conflicts between two modules are the most dangerous source of bugs. Virtual types can complement or replace preferences whenever no duplicate PHP code is needed.

Interface Binding

Always required: for="...Interface" type="...Implementation". Interfaces cannot be instantiated, a preference is mandatory.

Class Override

Only when new methods must be added. Conflicts when two modules are involved. New class must extend and inherit from the original.

Prefer plugin

Always use a plugin for method modification. Multiple plugins can be combined, preferences override each other. Upgrade safe.

Debugging

bin/magento dev:di:info ClassName shows the active preference. After changes: setup:di:compile plus cache:flush.

Mironsoft

Using preferences and plugins correctly?

We build Magento 2 modules with the correct use of preferences for interface bindings and plugins for extensions: upgrade safe, conflict free, and with a complete code review.

DI analysis

Detecting preference conflicts and replacing them with plugins

Service contracts

Interface architecture with correct preference bindings

Plugin development

Before, around, and after plugins for core extensions

FAQ: preferences in Magento 2

1 What is a preference in Magento 2?

Tells the DI container which concrete class should be used for an interface or another class. Two scenarios: interface binding (always required when interfaces are injected) and class override (adding new methods, use with caution).

2 When do I need to define a preference?

Whenever an interface is used via constructor injection. Without a preference: DI exception. For custom modules, always: RepositoryInterface → Repository, DataInterface → Model, SearchResultsInterface → SearchResults.

3 Preference or plugin, which to choose?

Preference: adding new methods or binding an interface. Plugin: modifying existing methods. Plugins can be combined without conflicts, multiple modules can extend the same method via plugin. Preferences override each other.

4 What is a preference conflict between two modules?

Two modules set a preference for the same class. Depending on sequence in module.xml, one preference wins. The other is silently ignored. Solution: module B inherits from module A's class, or both use plugins.

5 What is interface-first design in Magento 2?

Define the interface first, then the implementation. Other modules only know the interface. Implementation can be swapped via preference without breaking changes. Api/Data/ for data interfaces, Api/ for repository interfaces.

6 How do I debug the active preference?

bin/magento dev:di:info 'Class' shows the active preference and plugins. Set an Xdebug breakpoint in the suspected class, the stack trace shows the loaded class. Inspect generated/code/ after compiling.

7 Can a virtual type replace a preference?

Virtual types can serve as a preference for interfaces: <preference for="Interface" type="VirtualTypeName"/>. They cannot replace class overrides when new PHP methods are needed. For configuration variants of a class, virtual types are better than a preference.

8 How do you define area-specific preferences?

etc/adminhtml/di.xml for admin. etc/frontend/di.xml for frontend. etc/webapi_rest/di.xml for REST. Area-specific takes priority over the global etc/di.xml. Ideal for cached vs. uncached implementations.

9 Can a preference override a final class?

No. A final class cannot be extended. Magento marks some core classes as final to prevent uncontrolled overriding. For final classes: use an around plugin or composition instead of inheritance.

10 What do you need to do after a preference change?

Run bin/magento setup:di:compile plus cache:flush. In dev mode, cache:flush is often enough. In production, always explicitly run di:compile before going live. Without compiling, the preference change is not active in production.