← Back to blog

Magento 2 Dependency Injection and Virtual Types Guide

Rodrigo Biazzi

Dependency injection in Magento 2 is mostly configuration. The PHP side is plain constructor injection, but the behavior you get depends on di.xml files spread across every enabled module, merged by area, and compiled into generated/metadata in production. Once you understand how that merge works, virtual types stop looking like magic and become one of the most useful tools in the framework.

Everything here applies to Magento Open Source and Adobe Commerce 2.4.8.

Constructor injection and the object manager

A class declares what it needs in its constructor. The object manager reads the constructor signature, resolves each type, and builds the object. You never call new for services and you should not call the object manager directly in your own classes.

<?php
declare(strict_types=1);

namespace Acme\Feed\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Psr\Log\LoggerInterface;

class FeedBuilder
{
    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig,
        private readonly LoggerInterface $logger,
        private readonly string $fileName = 'feed.xml',
        private readonly array $exporters = []
    ) {
    }
}

Interfaces are resolved through preferences. Scalar and array parameters get their default values unless di.xml provides something else. That second part is where most of the flexibility comes from.

Preferences

A preference maps an interface (or class) to the implementation the object manager should create:

<preference for="Acme\Feed\Api\FeedRepositoryInterface" type="Acme\Feed\Model\FeedRepository"/>

A preference for an interface you own is normal. A preference that replaces a core class is a different thing. Only one preference can win for a given type, and the last module in load order takes it. Two extensions that both prefer Magento\Checkout\Model\Session will silently break one another. For changing behavior of existing classes, plugins are almost always the better option. The comparison of plugins, observers and preferences goes into that choice in detail.

Type arguments

The <type> node lets you set constructor arguments for a class without touching its code:

<type name="Acme\Feed\Model\FeedBuilder">
    <arguments>
        <argument name="fileName" xsi:type="string">google_feed.xml</argument>
        <argument name="exporters" xsi:type="array">
            <item name="price" xsi:type="object">Acme\Feed\Model\Exporter\Price</item>
            <item name="stock" xsi:type="object">Acme\Feed\Model\Exporter\Stock</item>
        </argument>
    </arguments>
</type>

The argument name must match the PHP parameter name exactly. If you rename a constructor parameter, every di.xml that sets it stops working without an error, and the default value is used instead. That is worth remembering during refactors.

The common xsi:type values are:

  • string, boolean, number and null for scalars.
  • object for a class or virtual type name. The object manager creates or shares it.
  • array with nested <item> nodes.
  • const to use a class constant, for example Magento\Store\Model\ScopeInterface::SCOPE_STORE.
  • init_parameter to read a value passed to the application at bootstrap.

Arrays merge across modules by item name. If your module adds an item named stock to the same argument, it replaces the existing stock item. A new name adds a new entry. This is how core pools are extended, such as the commands list for the CLI described in the custom CLI command guide, or the modifier pools of product form UI components.

Virtual types

A virtual type is a named configuration of an existing class with different constructor arguments. There is no PHP file. The object manager creates an instance of the original class and injects the arguments you defined for the virtual name.

The classic example is a dedicated log file. Instead of writing a handler class and a logger class, you configure both:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <virtualType name="Acme\Feed\Logger\Handler" type="Magento\Framework\Logger\Handler\Base">
        <arguments>
            <argument name="fileName" xsi:type="string">/var/log/acme_feed.log</argument>
        </arguments>
    </virtualType>

    <virtualType name="Acme\Feed\Logger\Logger" type="Magento\Framework\Logger\Monolog">
        <arguments>
            <argument name="name" xsi:type="string">acmeFeed</argument>
            <argument name="handlers" xsi:type="array">
                <item name="file" xsi:type="object">Acme\Feed\Logger\Handler</item>
            </argument>
        </arguments>
    </virtualType>

    <type name="Acme\Feed\Model\FeedBuilder">
        <arguments>
            <argument name="logger" xsi:type="object">Acme\Feed\Logger\Logger</argument>
        </arguments>
    </type>
</config>

FeedBuilder still type hints Psr\Log\LoggerInterface. It receives a Monolog logger that writes to var/log/acme_feed.log and nothing else changes. The logger article, custom logger with Monolog, covers the logging side in more depth.

Naming virtual types with a namespace that looks like a class is a convention, not a requirement. It helps because the name shows up in stack traces and in dev:di:info output, and it makes clear which module owns it.

What virtual types cannot do

  • You cannot type hint a virtual type in PHP. It does not exist as a class. You inject it through a di.xml argument.
  • You cannot add methods. If you need different behavior and not just different configuration, write a real class.
  • Plugins declared on the original class also apply to the virtual type, because the instance is of that class. You cannot declare a plugin on the virtual type name itself. The plugin documentation lists virtual types as one of the cases plugins do not support.

Virtual types extending virtual types

The type attribute of a virtual type can point to another virtual type. Arguments are inherited and can be overridden. Core uses this a lot for collections and data providers. It is powerful, but a chain of three or four virtual types is hard to follow. If you find yourself reading through several files to know what a class receives, a small real class may be clearer.

Area scoping

Configuration in etc/di.xml is global. Files in etc/frontend/di.xml, etc/adminhtml/di.xml, etc/webapi_rest/di.xml, etc/webapi_soap/di.xml, etc/graphql/di.xml and etc/crontab/di.xml are merged on top of the global configuration only when that area is loaded.

Use this to keep frontend plugins out of the admin and API, and the opposite. A plugin that only matters on the storefront and is declared globally runs in cron, consumers and CLI commands too, which costs time and can cause surprising side effects when there is no session or no store context.

Shared and non shared instances

By default the object manager shares instances. Every class that injects FeedBuilder gets the same object during a request. For services that hold no request specific state, that is what you want.

If a class holds state that must not leak between users, set shared="false":

<type name="Acme\Feed\Model\Exporter\Batch" shared="false"/>

A non shared type is created fresh for each class that injects it, but each consumer still keeps its own instance for the whole request. If you need a new object every time you call a method, inject a factory instead.

Shared state matters more than before in 2.4.8 because of the work done for application server support with GraphQL. Classes that keep per request data in properties should reset it. Core uses Magento\Framework\ObjectManager\ResetAfterRequestInterface for that. If you write a module that runs in GraphQL, avoid storing request data in properties of shared services.

Debugging DI configuration

When a class does not receive what you expect, check the merged configuration instead of guessing:

bin/magento dev:di:info "Acme\Feed\Model\FeedBuilder"

The output shows the preference, constructor parameters with the resolved types and values, and the plugins attached to the class. It is the fastest way to find out that an argument name does not match or that another module overrides your preference.

Other things to check:

  • In developer mode, a stale class in generated/code can survive a constructor change. Remove the affected folder and reload.
  • After any di.xml change in production mode, run bin/magento setup:di:compile. The compiled metadata in generated/metadata is what production reads.
  • Check the area. A preference in etc/frontend/di.xml is invisible to a CLI command.
  • Check module load order with <sequence> in module.xml if two modules configure the same argument and the wrong one wins.

Practical guidelines

  • Inject interfaces, not concrete classes, whenever an interface exists.
  • Keep constructors free of logic. Do not load data or call services in the constructor, because the object may be created and never used.
  • Use virtual types for configuration differences, real classes for behavior differences.
  • Use proxies for heavy dependencies that are only needed in some code paths. The article on factories and proxies shows how.
  • Never use ObjectManager::getInstance() in new code. It hides dependencies and breaks testing. Core still has it in a few places for backward compatibility, which is not a reason to copy it.

FAQ

What is the difference between a virtual type and a preference?

A preference tells Magento which class to use when a type is requested. A virtual type creates a new name for an existing class with different constructor arguments. Preferences replace, virtual types configure.

Why are my di.xml arguments ignored?

The argument name usually does not match the constructor parameter name, the file is in the wrong area folder, or generated code is stale. Run bin/magento dev:di:info on the class to see what Magento actually resolved.

Can I use a virtual type as a constructor type hint?

No. Virtual types only exist in configuration. Type hint the original class or its interface and inject the virtual type through a di.xml argument.

Do I need to run setup:di:compile after editing di.xml?

In production mode, yes. In developer mode, cleaning the config cache is usually enough, plus removing generated classes if constructors changed.

I am available for freelance Magento and Adobe Commerce work. If your project has di.xml conflicts between extensions or you need a second pair of eyes on module architecture, get in touch.

Related articles