How to Build Your Own Dependency Injection Container

Andrew Carter
Share

A search for “dependency injection container” on packagist currently provides over 95 pages of results. It is safe to say that this particular “wheel” has been invented.

Square wheel?

However, no chef ever learned to cook using only ready meals. Likewise, no developer ever learned programming using only “ready code”.

In this article, we are going to learn how to make a simple dependency injection container package. All of the code written in this article, plus PHPDoc annotations and unit tests with 100% coverage is available at this GitHub repository. It is also listed on Packagist.

Planning Our Dependency Injection Container

Let us start by planning what it is that we want our container to do. A good start is to split “Dependency Injection Container” into two roles, “Dependency Injection” and “Container”.

The two most common methods for accomplishing dependency injection is through constructor injection or setter injection. That is, passing class dependencies through constructor arguments or method calls. If our container is going to be able to instantiate and contain services, it needs to be able to do both of these.

To be a container, it has to be able to store and retrieve instances of services. This is quite a trivial task compared to creating the services, but it is still worth some consideration. The container-interop package provides a set of interfaces that containers can implement. The primary interface is the ContainerInterface that defines two methods, one for retrieving a service and one for testing if a service has been defined.

interface ContainerInterface
{
    public function get($id);
    public function has($id);
}

Learning From Other Dependency Injection Containers

The Symfony Dependency Injection Container allows us to define services in a variety of different ways. In YAML, the configuration for a container might look like this:

parameters:
    # ...
    mailer.transport: sendmail

services:
    mailer:
        class:     Mailer
        arguments: ["%mailer.transport%"]
    newsletter_manager:
        class:     NewsletterManager
        calls:
            - [setMailer, ["@mailer"]]

The way Symfony splits the container configuration into configuration of parameters and services is very useful. This allows for application secrets such as API keys, encryption keys and auth tokens to be stored in parameters files that are excluded from source code repositories.

In PHP, the same configuration for the Symfony Dependency Injection component would look like this:

use Symfony\Component\DependencyInjection\Reference;

// ...
$container->setParameter('mailer.transport', 'sendmail');

$container
    ->register('mailer', 'Mailer')
    ->addArgument('%mailer.transport%');

$container
    ->register('newsletter_manager', 'NewsletterManager')
    ->addMethodCall('setMailer', array(new Reference('mailer')));

By using a Reference object in the method call to setMailer, the dependency injection logic can detect that this value should not be passed directly, but replaced with the service that it references in the container. This allows for both PHP values and other services to be easily injected into a service without confusion.

Getting Started

The first thing to do is create a new project directory and make a composer.json file that can be used by Composer to autoload our classes. All this file does at the moment is map the SitePoint\Container namespace to the src directory.

{
    "autoload": {
        "psr-4": {
            "SitePoint\\Container\\": "src/"
        }
    },
}

Next, as we are going to make our container implement the container-interop interfaces, we need to make composer download them and add them to our composer.json file:

composer require container-interop/container-interop

Along with the primary ContainerInterface, the container-interop package also defines two exception interfaces. The first for general exceptions encountered creating a service and another for when a service that has been requested could not be found. We will also add another exception to this list, for when a parameter that has been requested cannot be found.

As we do not need to add any functionality beyond what is offered by the core PHP Exception class, these classes are pretty simple. Whilst they might seem pointless, splitting them up like this allows us to easily catch and handle them independently.

Make the src directory and create these three files at src/Exception/ContainerException.php, src/Exception/ServiceNotFoundException.php and src/Exception/ParameterNotFoundException.php respectively:

<?php

namespace SitePoint\Container\Exception;

use Interop\Container\Exception\ContainerException as InteropContainerException;

class ContainerException extends \Exception implements InteropContainerException {}
<?php

namespace SitePoint\Container\Exception;

use Interop\Container\Exception\NotFoundException as InteropNotFoundException;

class ServiceNotFoundException extends \Exception implements InteropNotFoundException {}
<?php

namespace SitePoint\Container\Exception;

class ParameterNotFoundException extends \Exception {}

Container References

The Symfony Reference class discussed earlier allowed the library to distinguish between PHP values to be used directly and arguments that needed to be replaced by other services in the container.

Let us steal that idea, and create two classes for references to parameters and services. As both of these classes are going to be value objects storing just the name of the resource that they refer to, it makes sense to use an abstract class as a base. That way we do not have to write the same code twice.

Create the following files at src/Reference/AbstractReference.php, src/Reference/ServiceReference.php and src/Reference/ParameterReference.php respectively:

<?php

namespace SitePoint\Container\Reference;

abstract class AbstractReference
{
    private $name;

    public function __construct($name)
    {
        $this->name = $name;
    }

    public function getName()
    {
        return $this->name;
    }
}
<?php

namespace SitePoint\Container\Reference;

class ServiceReference extends AbstractReference {}
<?php

namespace SitePoint\Container\Reference;

class ParameterReference extends AbstractReference {}

The Container Class

It is now time to create our container. We are going to start with a basic sketch map of our container class, and we will add methods to this as we go along.

The general idea will be to accept two arrays in the constructor of our container. The first array will contain the service definitions and the second will contain the parameter definitions.

At src/Container.php, place the following code:

<?php

namespace SitePoint\Container;

use Interop\Container\ContainerInterface as InteropContainerInterface;

class Container implements InteropContainerInterface
{
    private $services;
    private $parameters;
    private $serviceStore;

    public function __construct(array $services = [], array $parameters = [])
    {
        $this->services     = $services;
        $this->parameters   = $parameters;
        $this->serviceStore = [];
    }
}

All we are doing here is implementing the ContainerInterface from container-interop and loading the definitions into properties that can be accessed later. We have also created a serviceStore property, and initialized it to be an empty array. When the container is asked to create services, we will save these in this array so that they can be retrieved later without having to recreate them.

Now let us begin writing the methods defined by container-interop. Starting with get($name), add the following method to the class:

use SitePoint\Container\Exception\ServiceNotFoundException;

// ...
    public function get($name)
    {
        if (!$this->has($name)) {
            throw new ServiceNotFoundException('Service not found: '.$name);
        }

        if (!isset($this->serviceStore[$name])) {
            $this->serviceStore[$name] = $this->createService($name);
        }

        return $this->serviceStore[$name];
    }
// ...

Be sure to add the use statement to the top of the file. Our get($name) method simply checks to see if the container has the definition for a service. If it does not, the ServiceNotFoundException that we created earlier is thrown. If it does, it returns the service, creating it and saving it to the store if it has not already done so.

While we are at it, we should make a method for retrieving a parameter from the container. Assuming the parameters passed to the constructor form an N-dimensional associative array, we need some way of cleanly accessing any element within that array using a single string. An easy way of doing this is to use . as a delimiter, so that the string foo.bar refers to the bar key in the foo key of the root parameters array.

use SitePoint\Container\Exception\ParameterNotFoundException;

// ...
    public function getParameter($name)
    {
        $tokens  = explode('.', $name);
        $context = $this->parameters;

        while (null !== ($token = array_shift($tokens))) {
            if (!isset($context[$token])) {
                throw new ParameterNotFoundException('Parameter not found: '.$name);
            }

            $context = $context[$token];
        }

        return $context;
    }
// ...

Now, we have used a couple of methods that we have not yet written. The first of those is the has($name) method, that is also defined by container-interop. This is a pretty simple method, and it just needs to check if the definitions array provided to the constructor contains an entry for the $name service.

// ...
    public function has($name)
    {
        return isset($this->services[$name]);
    }
// ...

The other method we called that we are yet to write is the createService($name) method. This method will use the definitions provided to create the service. As we do not want this method to be called from outside the container, we shall make it private.

The first thing to do in this method is some sanity checks. For each service definition we require an array containing a class key and optional arguments and calls keys. These will be used for constructor injection and setter injection respectively. We can also add protection against circular references by checking to see if we have already attempted to create the service.

If the arguments key exists, we want to convert that array of argument definitions into an array of PHP values that can be passed to the constructor. To do this, we will need to convert the reference objects that we defined earlier to the values that they reference in from the container. For now, we will take this logic into the resolveArguments($name, array $argumentDefinitons) method. We use the ReflectionClass::newInstanceArgs() method to create the service using the arguments array. This is the constructor injection.

If the calls key exists, we want to use the array of call definitions and apply them to the service that we have just created. Again, we will take this logic into a separate method defined as initializeService($service, $name, array $callDefinitions). This is the setter injection.

use SitePoint\Container\Exception\ContainerException;

// ...
    private function createService($name)
    {
        $entry = &$this->services[$name];

        if (!is_array($entry) || !isset($entry['class'])) {
            throw new ContainerException($name.' service entry must be an array containing a \'class\' key');
        } elseif (!class_exists($entry['class'])) {
            throw new ContainerException($name.' service class does not exist: '.$entry['class']);
        } elseif (isset($entry['lock'])) {
            throw new ContainerException($name.' service contains a circular reference');
        }

        $entry['lock'] = true;

        $arguments = isset($entry['arguments']) ? $this->resolveArguments($name, $entry['arguments']) : [];

        $reflector = new \ReflectionClass($entry['class']);
        $service = $reflector->newInstanceArgs($arguments);

        if (isset($entry['calls'])) {
            $this->initializeService($service, $name, $entry['calls']);
        }

        return $service;
    }
// ...

That leaves us with two final methods to create. The first should convert an array of argument definitions into an array of PHP values. To do this it will need to replace ParameterReference and ServiceReference objects with the appropriate parameters and services from the container.

use SitePoint\Container\Reference\ParameterReference;
use SitePoint\Container\Reference\ServiceReference;

// ...
    private function resolveArguments($name, array $argumentDefinitions)
    {
        $arguments = [];

        foreach ($argumentDefinitions as $argumentDefinition) {
            if ($argumentDefinition instanceof ServiceReference) {
                $argumentServiceName = $argumentDefinition->getName();

                $arguments[] = $this->get($argumentServiceName);
            } elseif ($argumentDefinition instanceof ParameterReference) {
                $argumentParameterName = $argumentDefinition->getName();

                $arguments[] = $this->getParameter($argumentParameterName);
            } else {
                $arguments[] = $argumentDefinition;
            }
        }

        return $arguments;
    }

The last method performs the setter injection on the instantiated service object. To do this it needs to loop through an array of method call definitions. The method key is used to specify the method, and an optional arguments key can be used to provide arguments to that method call. We can reuse the method we just wrote to translate those arguments into PHP values.

    private function initializeService($service, $name, array $callDefinitions)
    {
        foreach ($callDefinitions as $callDefinition) {
            if (!is_array($callDefinition) || !isset($callDefinition['method'])) {
                throw new ContainerException($name.' service calls must be arrays containing a \'method\' key');
            } elseif (!is_callable([$service, $callDefinition['method']])) {
                throw new ContainerException($name.' service asks for call to uncallable method: '.$callDefinition['method']);
            }

            $arguments = isset($callDefinition['arguments']) ? $this->resolveArguments($name, $callDefinition['arguments']) : [];

            call_user_func_array([$service, $callDefinition['method']], $arguments);
        }
    }
}

And we now have a usable dependency injection container! To see usage examples, check out the repository on GitHub.

Finishing Thoughts

We have learned how to make a simple dependency injection container, but there are loads of containers out there with cool features that ours does not have yet!

Some dependency injection containers, such as PHP-DI and Aura.Di provide a feature called auto-wiring. This is where the container guesses which services from the container should be injected into others. To do this, they use the reflection API to find out information about the constructor parameters.

Feel free to fork the repository and add features such as auto-wiring or whatever else you can think of, it’s great practice! Furthermore, we keep a public list all known forks of this container so that others can see the work you have done. Just use the comments below to share your work with us, and we will make sure it gets added.

You can also use the comments below to get in touch. Let us know about anything that you would like clarified or explained, or any bugs that you have spotted.

Keep your eyes open for more articles like this on SitePoint PHP. We will soon be explaining how to reinvent the wheel with a range of common PHP components!