DI container

DI container

The biggest chunk of knowledge that carries over. Constructor injection is the same, but the container is compiled into PHP, all services are private, and most wiring is inferred from types without any configuration.

Basic configuration

Symfony · config/services.yaml (default)

services:
    _defaults:
        autowire: true        # constructor arguments by type
        autoconfigure: true   # tags by interfaces and attributes

    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

Every class in src/ automatically becomes a service whose id is its FQCN. autoconfigure means: if a class implements Command, EventSubscriberInterface or has the #[AsCommand] attribute, it gets registered where needed without any XML. Compare that with Magento, where you have to add a command to the commands array in di.xml.

preference → alias

Magento · etc/di.xml

<preference
    for="Vendor\Shipping\Api\RateProviderInterface"
    type="Vendor\Shipping\Model\DhlRateProvider"/>

Symfony · an attribute on the implementation

use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(RateProviderInterface::class)]
final class DhlRateProvider implements RateProviderInterface
{
}

or in services.yaml:

  #  ...
  App\Shipping\RateProviderInterface: '@App\Shipping\DhlRateProvider'
  #  ...

If an interface has a single implementation in src/, you don't need an alias at all. If there are two implementations, the container fails at compile time with a message asking which one to pick. In the same situation Magento silently takes the last preference by module order.

virtualType and arguments → named services

Magento · a separate log file via virtualType

<virtualType name="Vendor\Faq\Logger\Handler"
             type="Magento\Framework\Logger\Handler\Base">
    <arguments>
        <argument name="fileName" xsi:type="string">/var/log/faq.log</argument>
    </arguments>
</virtualType>
<virtualType name="Vendor\Faq\Logger"
             type="Magento\Framework\Logger\Monolog">
    <arguments>
        <argument name="name" xsi:type="string">faq</argument>
        <argument name="handlers" xsi:type="array">
            <item name="file" xsi:type="object">Vendor\Faq\Logger\Handler</item>
        </argument>
    </arguments>
</virtualType>
<type name="Vendor\Faq\Model\Importer">
    <arguments>
        <argument name="logger" xsi:type="object">Vendor\Faq\Logger</argument>
    </arguments>
</type>

Symfony · a Monolog channel + autowiring by name

# config/packages/monolog.yaml
monolog:
    channels: ['faq']
    handlers:
        faq:
            type: stream
            path: '%kernel.logs_dir%/faq.log'
            channels: ['faq']
// src/Faq/Importer.php
final class Importer
{
    public function __construct(
        private LoggerInterface $faqLogger,
    ) {}
}

The argument name $faqLogger selects the faq channel. Scoped HTTP clients (HttpClientInterface $erpClient), cache pools and lock factories work the same way. The general case, when you need to pass specific values:

Symfony · explicit arguments via attributes

use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class QuestionSearch
{
    public function __construct(
        #[Autowire(service: 'app.search.faq_client')]
        private SearchClient $client,
        #[Autowire(env: 'int:FAQ_PAGE_SIZE')]
        private int $pageSize,
        #[Autowire('%kernel.project_dir%/var/import')]
        private string $importDir,
    ) {}
}

To list all types available for autowiring and all named variants: bin/console debug:autowiring, for example debug:autowiring logger.

Plugin → decorator

Magento · an after plugin

<type name="Vendor\Catalog\Model\PriceCalculator">
    <plugin name="vendor_promo_discount"
            type="Vendor\Promo\Plugin\ApplyDiscount"
            sortOrder="10"/>
</type>
class ApplyDiscount
{
    public function afterCalculate(
        PriceCalculator $subject,
        float $result
    ): float 
    {
        return $result * 0.9;
    }
}

Symfony · a service decorator

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: PriceCalculatorInterface::class, priority: 10)]
final class DiscountedPriceCalculator implements PriceCalculatorInterface
{
    public function __construct(
        #[AutowireDecorated]
        
        private PriceCalculatorInterface $inner,
    ) {}

    public function calculate(Product $product): float
    {
        return $this->inner->calculate($product) * 0.9;
    }
}

Everyone who asks for PriceCalculatorInterface gets the decorator, and it calls the original. Differences from plugins:

  • You decorate a service, not a method. You have to implement the whole interface, so interfaces with many methods are awkward to decorate.
  • Entities, DTOs and objects created with new can't be decorated: they are not services.
  • If a consumer type-hints a concrete final class, you can't substitute a decorator. That's why depending on interfaces matters in Symfony code.
When you need to change someone else's behavior
  1. Look for an extension point in the bundle: a config option, an event, a tag. Check bin/console config:dump-reference bundle_name.
  2. Decorate the service.
  3. Override the service: an alias to your class in services.yaml.
  4. A compiler pass that modifies the service definition. This is a last resort.

Don't edit code in vendor/ or copy bundle classes.

Arrays in di.xml → tags

Magento · a pool of implementations

<type name="Vendor\Export\Model\ExportPool">
    <arguments>
        <argument name="formats" xsi:type="array">
            <item name="csv" xsi:type="object">Vendor\Export\Model\Format\Csv</item>
            <item name="xml" xsi:type="object">Vendor\Export\Model\Format\Xml</item>
        </argument>
    </arguments>
</type>

Symfony · a tag + a locator

#[AutoconfigureTag('app.export_format')]
interface ExportFormat
{
    public function export(iterable $rows): string;
}

#[AsTaggedItem('csv')]
final class CsvFormat implements ExportFormat { /* … */ }

final class ExportPool
{
    public function __construct(
        #[AutowireLocator('app.export_format')]
        private ContainerInterface $formats,
    ) {}

    public function get(string $code): ExportFormat
    {
        return $this->formats->get($code);
    }
}

A new format is just a new class implementing the interface. #[AutowireIterator] gives you an iterator over all implementations (with priorities), #[AutowireLocator] gives a lazy map by key.

Factory, Proxy, shared

Magento Symfony Note
QuestionFactory::create() new Question(...) Entities and DTOs are created with new, passing required fields to the constructor.
Factory for a service with a runtime parameter Your own factory class as a service The factory gets its dependencies via DI, and the parameter via the create() method.
Foo\Proxy in an argument #[Autowire(lazy: true)] or lazy: true The service is created on the first method call.
shared="false" shared: false or #[Autoconfigure(shared: false)] A new instance for every injection.
ObjectManager::getInstance() none Services are private: $container->get() won't work in code. And it shouldn't.

Useful commands: debug:container App\Faq\Importer (a service's arguments and tags), debug:container --tag=app.export_format, lint:container (checks argument types).