Injectable & Virtual Types in Magento 2
AI generated
mironsoft.de › Blog › Design Patterns
Magento 2 · Design Patterns
Injectable & Virtual Types
in Magento 2

Why can a Product object not be injected directly via the constructor, yet a ProductRepository can? The distinction between Injectable and Non-Injectable Objects is fundamental to Magento 2 DI. Virtual Types elegantly solve a common di.xml problem on top of that.

15 min read PHP 8.4 Magento 2.4.8

What are Injectable Objects?

Injectable Objects are the foundation of Magento 2 dependency injection. The term describes classes that can be safely wired into other classes via constructor injection, because they are stateless or at least request-scoped stateless. Stateless means: the class holds no variable state between individual method calls. Every call to an injectable class is independent of previous calls. That makes it safe to share, a single instance can be used from many places in the code without callers affecting one another.

Typical Injectable Objects in Magento 2 are services (classes that encapsulate business logic), repositories (that abstract database access behind an interface), loggers (that write log messages to files or other destinations), factories (that create new instances of Non-Injectable Objects) and helpers (that provide utility functions). What all of these classes have in common is that they hold no mutable state of their own and can therefore safely be managed as shared instances in the DI container.

The Magento 2 DI container manages Injectable Objects as shared instances by default. This means: when two different classes get the same PostRepositoryInterface injected, they receive the same repository instance, the container creates the repository only once. This behaviour is identical to the singleton pattern, but cleanly managed by the DI container rather than through a static getInstance() method. In PHP 8.4, with readonly properties and constructor property promotion, Injectable Objects can be implemented particularly cleanly and immutably.

A ViewModel in Magento 2 (a class implementing ArgumentInterface) is a classic example of an Injectable Object. It holds no request-specific data in its own state, instead computing and returning it fresh from its injected dependencies on every method call. That makes it safe to share across multiple block instances, even when they all receive the same ViewModel injected.

What are Non-Injectable Objects?

Non-Injectable Objects are the counterpart: classes that are stateful and store variable state. The most prominent example is the Magento 2 model, which represents a concrete database row. A Product object has an SKU, a price, a status and many other attributes, this data is the object's state, and it differs for every product. It makes no sense to share one and the same Product object across all products.

Collections are Non-Injectable Objects as well. A ProductCollection contains a particular set of products, filtered by particular criteria. This filtering and the resulting content are the state of the collection, and that state differs depending on the context in which the collection is used. If you injected the collection directly via the constructor, every injection point would receive the same empty (or already populated) collection, leading to incorrect results.

Value objects such as a Price or Address class are also non-injectable, because they store specific values that differ depending on context. A price object with the value 29.99 EUR is not the same as a price object with 59.99 CHF. Every use requires its own instance with its own values.

<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Service;

use Magento\Catalog\Model\ProductFactory;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Psr\Log\LoggerInterface;

/**
 * Product service, correct DI usage for injectable and non-injectable objects.
 */
class ProductService
{
    public function __construct(
        // CORRECT: ProductRepository is injectable, stateless service
        private readonly ProductRepositoryInterface $productRepository,
        // CORRECT: ProductFactory is injectable, stateless factory
        private readonly ProductFactory $productFactory,
        // CORRECT: Logger is injectable, stateless logging service
        private readonly LoggerInterface $logger
        // WRONG would be: private readonly Product $product
        // Product is non-injectable, it holds entity state
    ) {}

    /**
     * Create a new product with the given data.
     */
    public function createNewProduct(string $sku, float $price): \Magento\Catalog\Api\Data\ProductInterface
    {
        // Non-injectable: use factory to create a fresh instance
        $product = $this->productFactory->create();
        $product->setSku($sku);
        $product->setPrice($price);
        $product->setAttributeSetId(4);
        $product->setTypeId('simple');
        $product->setVisibility(4);
        $product->setStatus(1);

        $this->logger->info('Creating new product', ['sku' => $sku]);

        return $this->productRepository->save($product);
    }
}

Factories: the right way to handle Non-Injectable Objects

Factories are the link between the DI system and Non-Injectable Objects. A factory is itself an Injectable Object (stateless) and provides new instances of the associated Non-Injectable Object through its create() method. Magento 2 generates factory classes automatically during the compile process (setup:di:compile). The naming convention is remarkably simple: class name plus Factory. For the class Mironsoft\Blog\Model\Post, Mironsoft\Blog\Model\PostFactory is generated automatically.

The generated factory class lives in the generated/code/ directory and contains a single create() method that internally uses the ObjectManager to produce a new instance of the target class. This also resolves all constructor arguments of the target class through dependency injection. So you don't need to pass any arguments to create(), all dependencies are injected automatically. Optionally, you can pass an array with overridden arguments.

In PHP 8.4, with constructor property promotion and readonly properties, both the factory and the model it creates can be implemented very cleanly. The factory itself uses constructor property promotion for the ObjectManager. The created model object can declare readonly properties that should not be changed after creation, though that runs counter to the typical Magento 2 setData/getData architecture and is only recommended for genuinely immutable value objects.

A common mistake is confusing a factory with a repository. The factory creates new empty instances, it does not load data from the database. The repository loads existing entities, internally creates new instances via the factory, and populates them with database data. Factory and repository work together: the repository uses the factory internally to create fresh model instances, which are then populated with data from the resource model.

Shared vs. Non-Shared instances

The Magento 2 DI container distinguishes between shared and non-shared instances. Shared instances are created once and reused, which corresponds to the singleton pattern on demand. Non-shared instances are created fresh on every call. By default, all Injectable Objects are shared instances. Non-Injectable Objects created via factories are always non-shared instances.

The shared behaviour can be explicitly configured in di.xml. With shared="false" on a type element, the DI container is instructed to create a new instance on every injection. This is rarely necessary, but can make sense for classes that need to build up request-specific state that must differ between injection points.

In development with PHP 8.4, you can combine the shared behaviour with readonly properties. An Injectable Object that never changes its state after initialization can declare all its properties as readonly. The DI container creates the instance once, initializes all readonly properties in the constructor, and hands the same immutable instance to every dependent class. That corresponds to the concept of a true singleton, but it is managed by the DI container and fully replaceable with mocks in unit tests.

In everyday practice, the shared concept is particularly relevant for proxies. A proxy is a lazy-loading wrapper for an injectable class: the proxy itself is lightweight and is created immediately. The actual class (for example, a heavy service that establishes database connections) is only instantiated on the first real method call. That improves performance at bootstrap time, because heavy services are not always needed. Proxies are configured by appending \Proxy to the class name in the constructor.

Virtual Types in di.xml: concept and syntax

Virtual Types are one of the most elegant features of the Magento 2 DI system. A Virtual Type is a "class without a PHP file", it is defined exclusively in di.xml and configures an existing PHP class with different constructor arguments. The DI container treats the Virtual Type like a standalone class with a unique name. However, no PHP code is generated; internally, the Virtual Type points to the original PHP class with a specific DI configuration.

The core problem Virtual Types solve is code duplication caused by DI configuration. Imagine you need the same generic class in two different configurations, for example an HTTP client with two different base URLs. Without Virtual Types you would either have to write two PHP classes (a duplicate!) or pass the configuration at runtime (which makes testing harder). With Virtual Types you define two "virtual" classes in di.xml that use the same PHP code but receive different constructor arguments.

The syntax of a Virtual Type in di.xml is intuitive: you use the <virtualType> element with a name attribute (the unique class name of the Virtual Type) and a type attribute (the original PHP class). Inside the <virtualType> element, constructor arguments can be configured via <arguments>. These arguments override the default values of the original class.

Virtual Types can use other Virtual Types as a type argument. That enables a hierarchical configuration, as is typical for logger setup in Magento 2: a handler Virtual Type configures a Monolog handler with a particular file name. A logger Virtual Type configures a Monolog logger with that handler. The classes themselves remain the same PHP classes from the Magento framework, only the configuration differs.

Practical example: a custom module logger via Virtual Type

The most common and best-known use case for Virtual Types in Magento 2 is configuring a custom module logger. The goal: log messages from your own module should not end up in the general system.log, but in a dedicated file like var/log/mironsoft_blog.log. Without Virtual Types you would have to write your own PHP classes for the handler and the logger. With Virtual Types, a single di.xml configuration is enough.

The pattern is always two-stage: first you define a handler Virtual Type that configures the Monolog handler class with the desired file name. Then you define a logger Virtual Type that configures the Monolog logger class and registers the handler Virtual Type as its handler. Finally, you inject the logger Virtual Type into your own classes via a type configuration.

<?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">

    <!-- Step 1: Handler Virtual Type, configures log file destination -->
    <virtualType name="Mironsoft\Blog\Logger\Handler"
                 type="Magento\Framework\Logger\Handler\Base">
        <arguments>
            <argument name="fileName" xsi:type="string">/var/log/mironsoft_blog.log</argument>
        </arguments>
    </virtualType>

    <!-- Step 2: Logger Virtual Type, uses our custom handler -->
    <virtualType name="Mironsoft\Blog\Logger\Logger"
                 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\Handler</item>
            </argument>
        </arguments>
    </virtualType>

    <!-- Step 3: Inject the custom logger into our classes -->
    <type name="Mironsoft\Blog\Model\PostRepository">
        <arguments>
            <argument name="logger" xsi:type="object">Mironsoft\Blog\Logger\Logger</argument>
        </arguments>
    </type>

    <type name="Mironsoft\Blog\Service\PostImportService">
        <arguments>
            <argument name="logger" xsi:type="object">Mironsoft\Blog\Logger\Logger</argument>
        </arguments>
    </type>

</config>

The classes that get the logger injected do not need to know any of this. They simply inject LoggerInterface from PSR-3, and the DI container ensures that the matching Virtual Type logger is used. That is clean dependency injection: the class knows only the interface, not the concrete implementation or configuration.

A practical side effect of a module's own logger: when you need to analyze the log file of a particular module in production systems, you find all relevant entries bundled in a single file. That makes troubleshooting considerably easier compared to searching the general system.log for module-specific entries. In larger teams, where different developers are responsible for different modules, this is a substantial benefit for day-to-day work.

You can additionally adjust the log level of the Virtual Type logger. In the development environment you might want DEBUG-level logs, in production only WARNING and above. That can be configured via the level argument on the handler. Monolog supports all PSR-3 log levels: DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY. Through Virtual Type configuration, development and production configurations can be maintained in separate di.xml files for different Magento modes.

Practical example: API clients with Virtual Types

Another typical use case for Virtual Types is configuring multiple API clients with different endpoints. Suppose a module needs to communicate with both a payment API and a shipping API. Both APIs share the same basic request mechanism (authentication, timeout, error handling), but differ in their base URL and possibly in their timeout.

Without Virtual Types, two options would be conceivable: either you write two PHP classes, PaymentApiClient and ShippingApiClient, both containing the same code and differing only in the base URL (code duplication). Or you pass the base URL as a method argument on every API call (worse encapsulation, harder to test). Virtual Types offer the clean third option: a generic ApiClient class configured with specific settings via Virtual Types.

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

    <!-- Payment API Client, configured via Virtual Type -->
    <virtualType name="Mironsoft\Integration\Model\PaymentApiClient"
                 type="Mironsoft\Integration\Model\ApiClient">
        <arguments>
            <argument name="baseUrl" xsi:type="string">https://payment.api.example.com/v2</argument>
            <argument name="timeout" xsi:type="number">30</argument>
            <argument name="retryCount" xsi:type="number">3</argument>
        </arguments>
    </virtualType>

    <!-- Shipping API Client, different URL and longer timeout -->
    <virtualType name="Mironsoft\Integration\Model\ShippingApiClient"
                 type="Mironsoft\Integration\Model\ApiClient">
        <arguments>
            <argument name="baseUrl" xsi:type="string">https://shipping.api.example.com/v1</argument>
            <argument name="timeout" xsi:type="number">60</argument>
            <argument name="retryCount" xsi:type="number">2</argument>
        </arguments>
    </virtualType>

    <!-- Services receive their specific client via DI -->
    <type name="Mironsoft\Integration\Service\PaymentService">
        <arguments>
            <argument name="apiClient" xsi:type="object">
                Mironsoft\Integration\Model\PaymentApiClient
            </argument>
        </arguments>
    </type>

    <type name="Mironsoft\Integration\Service\ShippingService">
        <arguments>
            <argument name="apiClient" xsi:type="object">
                Mironsoft\Integration\Model\ShippingApiClient
            </argument>
        </arguments>
    </type>

</config>

The PHP class ApiClient contains all the HTTP code. It accepts baseUrl, timeout and retryCount via constructor property promotion. PaymentService and ShippingService inject ApiClient and know nothing about the Virtual Type details. The DI container ensures that every service gets the right client with the right configuration. No PHP duplication, no runtime configuration.

Debugging DI problems

Problems with Injectable Objects, factories and Virtual Types typically show up as exceptions while loading a page or running CLI commands. The most common error types are: ReflectionException when a class cannot be found, ObjectManager Exception when an interface has no preference, and silent failures when the wrong Virtual Type is configured.

The first step in debugging is always bin/magento setup:di:compile. This command reads all di.xml files, generates factories and proxies, and reports syntax errors in the DI configuration. A misspelled Virtual Type name, a nonexistent class name in the type attribute, or an incorrect argument format will be reported here. Without this step you cannot systematically resolve DI problems.

The command bin/magento dev:di:info 'Mironsoft\Blog\Logger\Logger' outputs detailed information about a Virtual Type or a class: which class is actually instantiated, which constructor arguments are used, which plugins are registered, and whether the type is shared or non-shared. This command is indispensable for understanding what the DI container is doing internally.

In the generated/code/ directory, you can inspect the generated classes after compiling. Factories have a simple structure and are easy to understand. If you suspect a factory was not generated correctly, you can look at it directly. Interceptors (for plugins) and proxies are more complex, but readable too. A look at the generated code often helps you understand why the system behaves differently than expected.

Summary: Injectable Objects & Virtual Types

Injectable Objects are stateless services that can safely be shared via constructor injection. Non-Injectable Objects like models and collections must always be instantiated through factories. Virtual Types enable multiple configurations of the same class without PHP duplication, the ideal way to build module loggers, API clients and configurable services in Magento 2.4.8.

Injectable = Stateless

Services, repositories, loggers, factories. One instance suffices for every call. Shared by default in the DI container.

Non-Injectable = Stateful

Models, collections, entities. A new instance per use via Factory::create(). Never inject directly.

Factory Auto-Generation

setup:di:compile automatically generates PostFactory for Post. Lives in the generated/ directory. Just inject the class name plus Factory.

Virtual Type

A new "class" without a PHP file, just DI configuration. Same code, different constructor arguments. Ideal for loggers and API clients.

Mironsoft

Building DI architecture for Magento 2?

We build clean Magento 2 modules with correct Injectable/Non-Injectable separation, automatically generated factories, and Virtual Types for conflict-free multi-configuration. Upgrade-safe and fully tested.

Factory Design

Correct factory usage for Non-Injectable Objects

Virtual Types

Loggers, API clients and services without code duplication

DI Audit

Analyzing DI configuration and fixing Non-Injectable mistakes

FAQ: Injectable & Virtual Types in Magento 2

1 What are Injectable Objects in Magento 2?

Injectable Objects are stateless or request-scoped classes that can safely be shared via constructor injection. This includes services, repositories, loggers, factories and helpers. The DI container manages them as shared instances within a request.

2 What are Non-Injectable Objects?

Non-Injectable Objects are stateful classes that store variable state: models, collections, value objects. Every use requires its own instance. You inject the factory and call create() to obtain new instances.

3 What are Virtual Types in Magento 2?

Virtual Types are DI configurations that define a new class without its own PHP file. Same PHP code, different constructor arguments. Ideal for loggers with different files or API clients with different base URLs. No code duplication.

4 What is the difference between Shared and Non-Shared instances?

Shared instances are created once and reused for all requests (singleton behaviour). Non-Shared instances are created fresh on every call. Injectable Objects are shared by default. Factory-created Non-Injectables are always non-shared.

5 How does Magento 2 create Factories automatically?

setup:di:compile generates factory classes in the generated/ directory. Naming: class name plus Factory. PostFactory for Post, ProductFactory for Product. Just inject it into the constructor.

6 How do I configure my own module logger?

Two Virtual Types: a handler (base handler with fileName) and a logger (Monolog with name and handlers). Inject it into your own classes via a type configuration. The log file ends up in var/log/mymodule.log.

7 Can a Virtual Type implement an interface?

Virtual Types automatically inherit the interfaces of the base class. You can set a Virtual Type as a preference for an interface: <preference for="Interface" type="VirtualTypeName"/>. That way, every caller of the interface gets the Virtual Type.

8 How do I recognize whether a class is injectable?

Rule of thumb: stateless equals injectable. Stateful entity/model equals non-injectable, use a factory. All classes that extend AbstractModel or AbstractCollection are non-injectable.

9 What is the difference between a Factory and a Proxy?

A factory creates new instances of Non-Injectable Objects via create(). A proxy is a lazy-loading wrapper for Injectable Objects, it creates the instance only on the first method call. Proxies are used for heavy services that are not always needed.

10 How do I debug Virtual Type problems?

bin/magento dev:di:info 'VirtualTypeName' shows the complete configuration. setup:di:compile shows syntax errors. Inspect the generated class in the generated/ directory. Virtual Type names must be unique.