Magento 2 Dependency Injection and Virtual Types Guide
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,numberandnullfor scalars.objectfor a class or virtual type name. The object manager creates or shares it.arraywith nested<item>nodes.constto use a class constant, for exampleMagento\Store\Model\ScopeInterface::SCOPE_STORE.init_parameterto 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.xmlargument. - 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/codecan survive a constructor change. Remove the affected folder and reload. - After any
di.xmlchange in production mode, runbin/magento setup:di:compile. The compiled metadata ingenerated/metadatais what production reads. - Check the area. A preference in
etc/frontend/di.xmlis invisible to a CLI command. - Check module load order with
<sequence>inmodule.xmlif 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
Magento 2 Custom Log File with Monolog (No Extra Classes)
Write Magento 2 logs to a custom file with Monolog using virtual types in di.xml, plus custom handlers, log levels, exception context and log rotation tips.
Magento 2 Plugins vs Observers vs Preferences: When to Use Each
How plugins, observers and preferences work in Magento 2.4.8, what each cannot do, and a checklist for choosing the one that survives upgrades and extensions.
AI Tools for Magento Development: What Works, What Fails
Using AI coding assistants on Magento 2 projects: where they save time, where they invent code, how to give them context and how to verify their output.
Writing Alpine.js Components in Hyvä: Patterns That Scale
How to write Alpine.js components on Hyvä: passing PHP data, view models, customer data events, form keys, component events, CSP and keeping templates clean.