Dependency Injection
in Magento 2
The foundation of all Magento architecture: constructor injection, di.xml, preferences, virtual types, shared objects and clean testing with mocks, explained in full.
Table of Contents
- 1. The core principle: Inversion of Control
- 2. Constructor injection and property promotion
- 3. di.xml: the configuration language
- 4. Preferences: binding interfaces
- 5. Arguments: injecting configuration
- 6. Virtual types: configuring classes without subclasses
- 7. Shared vs. non-shared objects
- 8. DI scopes: global, frontend, adminhtml
- 9. Testing: mocks and DI
- 10. Common mistakes
- 11. Summary
- 12. FAQ
1. The core principle: Inversion of Control
DI is a concrete implementation of the Inversion of Control (IoC) principle: instead of a class controlling its own dependencies (creating, configuring them), a higher-level container takes over that control and "injects" the dependencies from the outside.
- Testability: in a test, you can inject a mock implementation without changing the class itself.
- Interchangeability: the implementation behind an interface can be swapped through configuration.
- Loose coupling: classes only know the interface, not the concrete implementation.
- Declarative configuration: which class applies to which interface is defined in XML, clear and centralized.
2. Constructor injection and property promotion
Magento 2 exclusively supports constructor injection. Property injection and setter injection are not used. The constructor is the only place where all of a class's dependencies are visible. Since PHP 8.1, constructor property promotion combines declaration and assignment in a single line:
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\Model;
use Psr\Log\LoggerInterface;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Mironsoft\Blog\Api\Data\PostInterfaceFactory;
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
/**
* Blog post management service.
*/
class PostManager
{
/**
* PHP 8.1+ Constructor Property Promotion with readonly.
*/
public function __construct(
private readonly PostRepositoryInterface $postRepository,
private readonly PostInterfaceFactory $postFactory,
private readonly TimezoneInterface $timezone,
private readonly LoggerInterface $logger
) {}
public function publish(int $postId): void
{
$post = $this->postRepository->getById($postId);
$post->setStatus('published');
$post->setPublishedAt($this->timezone->date()->format('Y-m-d H:i:s'));
$this->postRepository->save($post);
$this->logger->info('Post published', ['post_id' => $postId]);
}
}
3. di.xml: the DI container's configuration language
The di.xml file is the central configuration file for the ObjectManager. It describes which concrete classes are used for interfaces, how arguments are passed and which plugins are active:
<?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">
<!-- 1. Preference: bind an interface to an implementation -->
<preference for="Mironsoft\Blog\Api\PostRepositoryInterface"
type="Mironsoft\Blog\Model\PostRepository"/>
<!-- 2. Arguments: specific arguments for a class -->
<type name="Mironsoft\Blog\Model\PostManager">
<arguments>
<argument name="logger" xsi:type="object">Mironsoft\Blog\Logger\BlogLogger</argument>
</arguments>
</type>
<!-- 3. Plugin: add behavior to a method -->
<type name="Mironsoft\Blog\Model\PostRepository">
<plugin name="mironsoft_blog_cache_invalidator"
type="Mironsoft\Blog\Plugin\PostCacheInvalidator"
sortOrder="20"/>
</type>
</config>
4. Preferences: binding interfaces to implementations
A <preference> element binds an interface to a concrete implementation. When the ObjectManager needs to instantiate a class with this interface as a dependency, it uses the configured preference:
<!-- Always bind your own interfaces, this is required! -->
<preference for="Mironsoft\Blog\Api\PostRepositoryInterface"
type="Mironsoft\Blog\Model\PostRepository"/>
<preference for="Mironsoft\Blog\Api\Data\PostInterface"
type="Mironsoft\Blog\Model\Post"/>
<!-- Overriding core classes: CAUTION, only when necessary! -->
<!-- Use a plugin instead! -->
<preference for="Magento\Customer\Api\CustomerRepositoryInterface"
type="Mironsoft\Blog\Model\CustomerRepositoryExtended"/>
5. Arguments: injecting configuration
With <arguments> you can pass specific instances or configuration values to certain classes:
<type name="Mironsoft\Blog\Model\Notifier">
<arguments>
<!-- Inject a string value -->
<argument name="senderEmail" xsi:type="string">blog@mironsoft.de</argument>
<!-- Inject a boolean -->
<argument name="enableNotifications" xsi:type="boolean">true</argument>
<!-- Inject a number -->
<argument name="maxRetries" xsi:type="number">3</argument>
<!-- Inject a specific object -->
<argument name="logger" xsi:type="object">Mironsoft\Blog\Logger\BlogLogger</argument>
<!-- Inject an array -->
<argument name="allowedStatuses" xsi:type="array">
<item name="draft" xsi:type="string">draft</item>
<item name="published" xsi:type="string">published</item>
</argument>
</arguments>
</type>
6. Virtual types: configuring classes without subclasses
A virtual type is a "virtual" class with no PHP file of its own: it configures an existing class with different constructor arguments. Ideal for loggers, API clients and configurable services:
<!-- Virtual type: a custom logger without a new PHP class -->
<virtualType name="Mironsoft\Blog\Logger\BlogHandler"
type="Magento\Framework\Logger\Handler\Base">
<arguments>
<argument name="fileName" xsi:type="string">/var/log/mironsoft_blog.log</argument>
</arguments>
</virtualType>
<virtualType name="Mironsoft\Blog\Logger\BlogLogger"
type="Magento\Framework\Logger\Monolog">
<arguments>
<argument name="name" xsi:type="string">mironsoft_blog</argument>
<argument name="handlers" xsi:type="array">
<item name="system" xsi:type="object">Mironsoft\Blog\Logger\BlogHandler</item>
</argument>
</arguments>
</virtualType>
<!-- Inject the virtual type just like a real class -->
<type name="Mironsoft\Blog\Model\PostManager">
<arguments>
<argument name="logger" xsi:type="object">Mironsoft\Blog\Logger\BlogLogger</argument>
</arguments>
</type>
7. Shared vs. non-shared objects
By default, the ObjectManager creates only a single instance per class (shared). Ideal for services and repositories since they hold no state. Entities and DTOs need a new instance on every call, which is what factories are for:
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\Model;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Api\Data\PostInterfaceFactory;
/**
* Uses Factory for new Post instances instead of direct injection.
*/
class PostService
{
public function __construct(
private readonly PostInterfaceFactory $postFactory
) {}
public function createDraft(string $title): PostInterface
{
// create() always returns a NEW instance
$post = $this->postFactory->create();
$post->setTitle($title);
$post->setStatus('draft');
return $post;
}
}
8. DI scopes: global, frontend, adminhtml
di.xml files are separated by area (scope). Different scopes allow different implementations for frontend and backend:
app/code/Mironsoft/Blog/etc/
├── di.xml ← applies globally (all scopes)
├── frontend/
│ └── di.xml ← applies only in the frontend
├── adminhtml/
│ └── di.xml ← applies only in the backend
└── webapi_rest/
└── di.xml ← applies only to REST API requests
9. Testing: DI enables clean unit tests
The biggest practical benefit of DI: mock objects can be injected as a substitute for real implementations. No database access, no filesystem operations, just clean, fast unit tests:
<?php
declare(strict_types=1);
namespace Mironsoft\Blog\Test\Unit\Model;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Mironsoft\Blog\Model\PostManager;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Api\Data\PostInterfaceFactory;
use Magento\Framework\Stdlib\DateTime\TimezoneInterface;
use Psr\Log\LoggerInterface;
class PostManagerTest extends TestCase
{
private PostManager $postManager;
private PostRepositoryInterface&MockObject $postRepositoryMock;
private LoggerInterface&MockObject $loggerMock;
protected function setUp(): void
{
$this->postRepositoryMock = $this->createMock(PostRepositoryInterface::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
$timezoneMock = $this->createMock(TimezoneInterface::class);
$timezoneMock->method('date')->willReturn(new \DateTime('2026-05-04 12:00:00'));
$postMock = $this->createMock(PostInterface::class);
$this->postRepositoryMock->method('getById')->willReturn($postMock);
$this->postManager = new PostManager(
$this->postRepositoryMock,
$this->createMock(PostInterfaceFactory::class),
$timezoneMock,
$this->loggerMock
);
}
public function testPublishLogsSuccessMessage(): void
{
$this->loggerMock
->expects($this->once())
->method('info')
->with('Post published', ['post_id' => 42]);
$this->postManager->publish(42);
}
}
10. Common mistakes with Dependency Injection
Mistake 1: using the ObjectManager directly
// WRONG, makes the code untestable and bypasses DI configuration
$repo = \Magento\Framework\App\ObjectManager::getInstance()->get(PostRepositoryInterface::class);
// CORRECT, constructor injection
public function __construct(private readonly PostRepositoryInterface $postRepository) {}
Mistake 2: injecting concrete classes instead of interfaces
// WRONG
use Mironsoft\Blog\Model\PostRepository;
public function __construct(PostRepository $postRepository) {}
// CORRECT
use Mironsoft\Blog\Api\PostRepositoryInterface;
public function __construct(PostRepositoryInterface $postRepository) {}
Mistake 3: injecting session objects directly
<!-- Always inject a session as a proxy, this avoids bootstrap problems -->
<type name="Mironsoft\Blog\Model\PostManager">
<arguments>
<argument name="customerSession" xsi:type="object">
Magento\Customer\Model\Session\Proxy
</argument>
</arguments>
</type>
Mironsoft
Magento 2 architecture & code quality
Need a Magento code review or architecture consulting?
We analyze existing Magento code for DI anti-patterns, ObjectManager misuse and upgrade risks. Clear recommendations and a migration roadmap included.
DI audit
Identify ObjectManager calls, core overrides and circular dependencies
Architecture review
di.xml configuration, preference vs. plugin decisions, virtual type design
Unit tests
Introduce a testable DI architecture, build up PHPUnit mocks and test coverage
11. Summary
Dependency Injection is the foundation of all Magento 2 development. It enables loose coupling, testability and interchangeability through consistent constructor injection, interface bindings via di.xml, and automatically generated factories and proxies.
Dependency Injection: the essentials at a glance
Constructor injection
Always through the constructor. Use constructor property promotion. readonly for immutable dependencies. Never call the ObjectManager directly.
Interfaces over implementations
Always inject the interface, never the implementation directly. Bind it via <preference> in di.xml. Avoid core preferences, prefer a plugin.
Factory for new objects
Never inject data objects directly, always use a factory. Auto-generated for every class: ClassNameFactory. create() returns a new instance.
Virtual types
Configure classes without PHP code. Ideal for loggers, decorators and wrappers with different configurations. Lives only in di.xml, no PHP file needed.
12. FAQ: Dependency Injection in Magento 2
1 What is the ObjectManager?
2 Why does setup:di:compile fail?
3 Plugin vs. preference: which to choose?
4 What are virtual types?
5 When to use a factory instead of direct injection?
Factory::create() returns a new, empty instance every time. Magento generates factories automatically.6 Area-specific DI configuration?
etc/frontend/di.xml, etc/adminhtml/di.xml, etc/webapi_rest/di.xml. Area-specific takes precedence over global (etc/di.xml). For different implementations per scope.7 How to inject a session correctly?
Magento\Customer\Model\Session\Proxy in di.xml. The proxy initializes the session only on the first method call (lazy loading).8 How do I test classes with DI?
$this->createMock(PostRepositoryInterface::class). Instantiate the class directly: new PostManager($repoMock, ...). No ObjectManager, no database access.9 What happens without a preference for an interface?
"Cannot instantiate interface X". Every interface needs a preference binding. The preference belongs in the di.xml of the module that defines the interface.10 Proxy vs. factory: what's the difference?
create(). Proxy: a lazy-loading wrapper for injectable objects, the instance is created only on the first method call. Both are generated automatically by Magento.