Skip to content

Dependency injection

Note

Further on, we'll refer to the dependency injection container simply as "container".

As previously mentioned here, the container can be configured differently for each application area.

For global configuration, create a file named Di.php in directory Configuration under a module's root directory.

For area-specific configuration, create a file named Di.php in one of the following directories under a module's root directory.

  • Configuration/Backend
  • Configuration/Frontend
  • Configuration/Cli

Tip

You can use the configurators variable to add other configurator classes that should be processed. This allows you to have multiple smaller configurators.

Binding


In the process of binding, we bind a type to an implementation which we call a "concrete".

A type can be a text identifier or an interface, while the concrete can be a classname or a closure. The result of a bind operation is a binding object wrapped in a closure, which will be executed to configure or instantiate the concrete implementation when required directly or by the dependency injection chain.

Binding objects determine how instances are created, which arguments are injected, which calls are made on the instances immediately after instantiation, are they supposed to be instantiated only once, and more.

Injecting a concrete implementation, like an instantiable class, requires no configuration. Also, by default, the class will be instantiated only once, meaning it will become a shared instance.

Important

All binding objects are configured to create shared instances by default, unless explicitly reconfigured otherwise.

Let's take a look at some common examples. Check the annotations to know more.

Binding with an interface


Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Vendor\ModuleName\Contract\AnInterface;
use Vendor\ModuleName\Implementation\AnImplementation;
use Override;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->bind(
                AnInterface::class,
                fn (ConfigurableContainerInterface $container) => $container
                    // (1)
                    ->getBindBuilder()
                    // (2)
                    ->type(AnInterface::class)
                    // (3)
                    ->concrete(AnImplementation::class)
                    // (4)
                    ->build()
            );
    }
}
  1. Always use the bind builder and always fetch it from the container from within the closure.
  2. The interface class becomes a unique identifier for this binding.
  3. The concrete implementation which implements the interface.
  4. The closure must return a binding object, so we call the builder's build method.

Binding with an id


Binding with an id is exactly the same as when binding with an interface, the key difference being the binding identifier.

Important

To be able to inject an identifier which is not a classname of interface or a class, the argument must be explicitly defined in the binding object or specified using the Amarant\Framework\Attribute\Di\Inject or Amarant\Framework\Attribute\Di\InjectIfDefined attributes on constructor parameters.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    ->bind(
        'some.unique.binding.identifier',
        fn (ConfigurableContainerInterface $container) => $container
            ->getBindBuilder()
            ->type(AnInterface::class)
            ->concrete(AnImplementation::class)
            ->build()
    );

Note

If we created both of these bindings, we would end up with two different AnImplementation class instances. Inside the container, one would be available under Vendor\ModuleName\Contract\AnInterface and the other under some.unique.binding.identifier id.

Specifying concrete as closure


Instead of specifying the implementation class, a closure can be used, allowing manual instantiation with access to the container.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    ->bind(
        AnInterface::class,
        fn (ConfigurableContainerInterface $container) => $container
            ->getBindBuilder()
            ->type(AnInterface::class)
            ->concrete(function (ConfigurableContainerInterface $container) {
                return new AnImplementation();
            })
            ->build()
    );

Arguments


Constructor arguments can be explicitly defined. All arguments that are not defined, will be injected.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    ->bind(
        AnInterface::class,
        fn (ConfigurableContainerInterface $container) => $container
            ->getBindBuilder()
            ->type(AnInterface::class)
            ->concrete(AnImplementation::class)
            ->argument(
                'someArgument',
                'some value'
            )
            ->build()
    );

Here we are injecting a simple text value into the constructor parameter someArgument of class Vendor\ModuleName\Implementation\AnImplementation.

We can do a lot more than that, by using different types of injections instead.

Calls


Calls added to a binding will be executed once on an object, immediately after its instantiation. It allows execution of methods on a new object.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    ->bind(
        AnInterface::class,
        fn (ConfigurableContainerInterface $container) => $container
            ->getBindBuilder()
            ->type(AnInterface::class)
            ->concrete(AnImplementation::class)
            ->call(
                'setSomeArgument',
                [
                    'some value'
                ]
            )
            ->build()
    );

The method name and arguments must be provided. Method arguments support injection the same way as constructor arguments do.

Shared instances


To set the bound instance as shared, thus producing a singleton, set the shared to true.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    ->bind(
        AnInterface::class,
        fn (ConfigurableContainerInterface $container) => $container
            ->getBindBuilder()
            ->type(AnInterface::class)
            ->concrete(AnImplementation::class)
            ->shared(true)
            ->build()
    );

This value is true by default. Setting it to false will create a new instance every time a binding is requested.

Templates


A binding can be made a template only. This means it's not meant to be instantiated but rather used as a template by another binding.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    // (1)
    ->bind(
        'some-template-binding-id',
        fn (ConfigurableContainerInterface $container) => $container
            ->getBindBuilder()
            // (2)
            ->template(true)
            ->type(AnInterface::class)
            ->concrete(AnImplementation::class)
            // (3)
            ->argument('someArgument', 'some value')
            ->build()
        )
    // (4)
    ->bind(
        'some-other-binding-id',
        fn (ConfigurableContainerInterface $container) => $container
            ->getBindBuilder()
            // (5)
            ->parent('some-template-binding-id')
            // (6)
            ->argument('someOtherArgument', 'some other value')
            ->build()
        );
  1. The template binding.
  2. Template must be set to true if we want the binding to be a template.
  3. We can add constructor arguments, the same as with regular binding.
  4. The binding that will use the template.
  5. Parent must be specified, using the identifier of the template binding.
  6. Constructor arguments can be added or overridden.

Bindings using a template do not specify type or concrete as they are inferred from the template.

Factories


A binding can specify another binding as its factory. This works similarly as calls. A binding id, method name and arguments are specified. The container fetches the instance under the given binding id, calls the method using the provided arguments and binds the result of the method as an instance.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    ->bind(
        AnInterface::class,
        fn (ConfigurableContainerInterface $container) => $container
            ->getBindBuilder()
            ->type(AnInterface::class)
            ->concrete(AnImplementation::class)
            ->factory(
                type: AnImplementationFactory::class,
                method: 'create',
                arguments: [
                    'someArgument' => 'some value',
                ]
            )
            ->build()
        );

Given the example above, we assume that the class AnImplementationFactory exists, has a public method create that accepts parameter someArgument and returns an instance that implements AnInterface interface.

If defined, this factory will be called every time an instance of AnInterface is requested by the container, unless it's shared and an instance of it already exists in the container.

Extending


An existing binding can be extended, allowing its configuration to be overridden.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    ->extend(
        AnInterface::class,
        function (BindInterface $bind, ConfigurableContainerInterface $container) {
            $bind
                ->getCreateArgument('someArgument')
                ->setValue('some other value');

            return $bind;
        }
    );

The closure receives the binding object Amarant\Framework\Contract\Di\BindInterface, allowing change to its parametrization. In the example above, the constructor argument someArgument is changed to a different value.

Tagging


Binding identifiers can be tagged, so they can be injected as a list. Tags themselves are just text labels.

Optionally, priority can be defined, affecting the order of objects in an injected list. Objects with higher priority are listed first.

Vendor/ModuleName/Configuration/Di.php
<?php

$container
    ->tag(
        [
            // (1)
            500 => AnInterface::class,
        ],
        // (2)
        ['some_tag']
    );
  1. Optional index which acts as priority, mapped to a binding identifier.
  2. A list of tags.

Injection


Injection can be done with:

  • binding id
  • classname of class or interface
  • tags
  • multiple tags
  • id named tags
  • class named tags
  • call
  • environment variables

All of these can be used wherever injection is supported:

  • constructor arguments in bindings
  • calls in bindings
  • factory in bindings
  • inject attributes on constructor arguments

These can be used as block arguments in the presentation layer layouts:

  • binding id

Warning

Always use Amarant\Framework\Enum\DiEnum static methods for injection. Do not hardcode injection prefixes.

Inject by id or classname


Inject using DI configurator

Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Amarant\Framework\Enum\DiEnum;
use Vendor\ModuleName\Contract\AnInterface;
use Vendor\ModuleName\Implementation\AnImplementation;
use Vendor\ModuleName\Implementation\AnotherImplementation;
use Override;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->bind(
                AnInterface::class,
                fn (ConfigurableContainerInterface $container) => $container
                    ->getBindBuilder()
                    ->type(AnInterface::class)
                    ->concrete(AnImplementation::class)
                    ->argument(
                        'someArgument',
                        DiEnum::getIdInjection(AnotherImplementation::class)
                    )
                    ->build()
            );
    }
}

Inject using an attribute

Vendor/ModuleName/Implementation/AnImplementation.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Implementation;

use Amarant\Framework\Attribute\Di\Inject;
use Vendor\ModuleName\Contract\AnInterface;

final class AnImplementation implements AnInterface
{
    public function __construct(
        #[Inject(AnotherImplementation::class)]
        private readonly AnotherImplementation $implementation
    ) {
    }
}

Important

Avoid using attribute for injection, to allow modules to define and override (extend) DI configuration using configurators.

A valid case for using an attribute would be to inject one specific implementation that wouldn't need to be changed by configurators. For example, using a specific logger instance.

Inject using an attribute - if defined

By default, a constructor argument that has a default value like null, and whose value isn't explicitly defined by a configurator and has no Inject attribute, will not be injected.

If a binding may not have been defined at all, you can use a different attribute for injection.

Vendor/ModuleName/Implementation/AnImplementation.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Implementation;

use Amarant\Framework\Attribute\Di\InjectIfDefined;
use Vendor\ModuleName\Contract\AnInterface;

final class AnImplementation implements AnInterface
{
    public function __construct(
        #[InjectIfDefined(AnotherImplementation::class)]
        private ?AnotherImplementation $implementation = null
    ) {
    }
}

In this case, if AnotherImplementation has a binding defined in the container, it will be injected. Otherwise, the argument stays intact with its default value of null.

Inject using tags


Inject object instances of binding identifiers tagged with one or many tags.

Check the annotations to know more.

Important

The constructor argument that receives the list, must be one of the following types:

  • array
  • iterable
  • Traversable
  • Amarant\Framework\Contract\Di\ArgumentIteratorInterface

Unless the type is array, Amarant\Framework\Contract\Di\ArgumentIteratorInterface will be injected.

Configure the injection

Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Amarant\Framework\Enum\DiEnum;
use Vendor\ModuleName\Contract\AnInterface;
use Vendor\ModuleName\Implementation\AnImplementation;
use Vendor\ModuleName\Implementation\AnotherImplementation;
use Override;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->bind(
                AnInterface::class,
                fn (ConfigurableContainerInterface $container) => $container
                    ->getBindBuilder()
                    ->type(AnInterface::class)
                    ->concrete(AnImplementation::class)
                    ->argument(
                        'argumentA',
                        // (1)
                        DiEnum::getTagInjection('tag.a')
                    )
                    ->argument(
                        'argumentB',
                        // (2)
                        DiEnum::getMultiTagInjection(['tag.b', 'tag.c'])
                    )
                    ->argument(
                        'argumentC',
                        // (3)
                        DiEnum::getTagIdNamedInjection('tag.d')
                    )
                    ->argument(
                        'argumentD',
                        // (4)
                        DiEnum::getTagClassNamedInjection('tag.e')
                    )
                    ->build()
            );
    }
}
  1. Inject instances of binding identifiers, tagged by a tag, into a list with numeric keys.
  2. Inject instances of binding identifiers, tagged by any of multiple tags, into a list with numeric keys.
  3. Inject instances of binding identifiers, tagged by a tag, into a list where keys are the binding identifiers.
  4. Inject instances of binding identifiers, tagged by a tag, into a list where keys are the object class names.

Use an injected list

Vendor/ModuleName/Implementation/AnImplementation.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Implementation;

use Amarant\Framework\Contract\Di\ArgumentIteratorInterface;
use Vendor\ModuleName\Contract\AnInterface;

final class AnImplementation implements AnInterface
{
    public function __construct(
        private readonly iterable $argumentA,   // (1)
        private readonly iterable $argumentB,   // (2)
        private readonly ArgumentIteratorInterface $argumentC,   // (3)
        private readonly iterable $argumentD,   // (4)
    ) {
    }

    public function __invoke(): void
    {
        // use getKeys, hasKey to check if keys exist
        if ($this->argumentC->hasKey('some.key') {
            // fetch an instance and call some method on it
            $this->argumentC->offsetGet('some.key')->doSomething();
        }

        // iterate through a list
        foreach ($this->argumentD as $className => $instance) {
            $instance->doSomething();
        }
    }
}
  1. An instance of Amarant\Framework\Contract\Di\ArgumentIteratorInterface containing object instances of all binding identifiers which were tagged by tag.a tag.
  2. An instance of Amarant\Framework\Contract\Di\ArgumentIteratorInterface containing object instances of all binding identifiers which were tagged by tag.b or tag.c tags.
  3. An instance of Amarant\Framework\Contract\Di\ArgumentIteratorInterface containing object instances of all binding identifiers which were tagged by tag.d tag.

    Iterator keys are binding identifiers.

  4. An instance of Amarant\Framework\Contract\Di\ArgumentIteratorInterface containing object instances of all binding identifiers which were tagged by tag.e tag.

    Iterator keys are class names.

Note

Objects in a list are instantiated lazyly, when the list is iterated or an offset is retrieved.

Call injection


This type of injection instantiates a binding identifier, calls a method with parameters on it, and uses the result as injection value.

Warning

The parameters should only have scalar values.

Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Amarant\Framework\Enum\DiEnum;
use Vendor\ModuleName\Contract\AnInterface;
use Vendor\ModuleName\Implementation\AnImplementation;
use Vendor\ModuleName\Implementation\AnotherImplementation;
use Override;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->bind(
                AnInterface::class,
                fn (ConfigurableContainerInterface $container) => $container
                    ->getBindBuilder()
                    ->type(AnInterface::class)
                    ->concrete(AnImplementation::class)
                    ->argument(
                        'someArgument',
                        DiEnum::getCallInjection(
                            'some.binding.id.or.classname',
                            'someMethod',
                            ['value 1', 'value 2']
                        )
                    )
                    ->build()
            );
    }
}

Environment injection


To inject environment variables, use the following example.

Vendor/ModuleName/Configuration/Di.php
<?php

declare(strict_types=1);

namespace Vendor\ModuleName\Configuration;

use Amarant\Framework\Contract\Di\ConfigurableContainerInterface;
use Amarant\Framework\Contract\Di\DiConfiguratorInterface;
use Amarant\Framework\Data\Set;
use Amarant\Framework\Enum\DiEnum;
use Vendor\ModuleName\Contract\AnInterface;
use Vendor\ModuleName\Implementation\AnImplementation;
use Vendor\ModuleName\Implementation\AnotherImplementation;
use Override;

class Di implements DiConfiguratorInterface
{
    #[Override] public function configure(ConfigurableContainerInterface $container, Set $configurators): void
    {
        $container
            ->bind(
                AnInterface::class,
                fn (ConfigurableContainerInterface $container) => $container
                    ->getBindBuilder()
                    ->type(AnInterface::class)
                    ->concrete(AnImplementation::class)
                    ->argument(
                        'someArgument',
                        DiEnum::getEnvInjection('SOME_ENVIRONMENT_VARIABLE_NAME')
                    )
                    ->build()
            );
    }
}

Using the container


Usually, you wouldn't inject and use a container directly. If you have a good reason to do so, you can use the following methods.

Note

The container is PSR-11 compliant.

First, inject the container into your class:
Amarant\Framework\Contract\Di\ContainerInterface

Get an instance using binding identifier (id or classname):
$instance = $this->container->get('some.id');

Get instances for bindings tagged with a tag:
$instances = $this->container->getTagged('some.tag');

Create a new instance using binding identifier (id or classname):
$instance = $this->container->create('some.id');

Remove all instances from the container:
$this->container->reset();

Remove all instances from the container, except the ones set with the instance method:
$this->container->resetExceptSetInstances();

Get the configuration storage object:
$configuration = $this->container->getConfigStorage();

Setting an instance of an object


During application initialization, a configurable container is passed on to various application loaders. At that time, an already instantiated object can be set on the container.

$container->instance('some.binding.identifier.or.class.name', $instance);

Note

After the application initializes, it cannot access the configurable container anymore. This means the instance method won't be available anymore. The reason for this is that an instance cannot be changed after it's already injected into other objects. The same reason applies why bindings and tags can only be applied on the configurable container.

The kernel allows adding a post load callback which has access to the configurable container. An instance can also be set using the callback.

See post load callbacks.