Creating an IoC container with PHP

Creating an IoC container with PHP

Mon Oct 03 20221,652 words

As projects grow larger managing dependencies between classes can become a major challenge.

An IoC (Inversion of control) container can be used to solve this problem. The container controls the injection of dependencies and acts as a layer where you can swop them out when needed.

Pretty much all modern PHP frameworks such as Laravel and Drupal use an IoC container.

This tutorial will teach you the basic concepts behind building an IoC container and introduce you to reflection - one of the most powerful features in PHP.

See the complete tutorial files in the PHP IoC Container example repository


Singleton container pattern

A container should be a singleton instance and act as a single source of truth when managing dependencies.

As static functions are global, they can be used to create and return the same container instance every time.

class Container
{
    public static function instance(): static
    {
        static $instance = null;
        if ($instance === null) {
            $instance = new static();
        }
        return $instance;
    }
}

$container = Container::instance(); // creates the singleton container instance
$container = Container::instance(); // returns the same singleton container instance

Setting container bindings

Inside the container we have an array of bindings which maps 2 different things.

  • namespaces - a mapping of [from-namespace] => [to-namespace]
  • singletons - a mapping of [singleton-namespace] => [singleton-instance]

This mapping is checked when resolving dependencies for class constructors and class methods or calling a resolve method.

class Container
{
    protected array $bindings = [];

    public function bind(string $id, string $namespace): Container
    {
        $this->bindings[$id] = $namespace;
        return $this;
    }

    public function singleton(string $id, object $instance): Container
    {
        $this->bindings[$id] = $instance;
        return $this;
    }
}

PSR container interface

The PHP Framework Interop Group has a set of PHP standard recommendations (PSR) and provides a set of base interfaces you can use to create standards compliant and portable code.

The PSR-11: Container interface has 2 methods get() and has() that the container can implement.

These methods check for entries in the bindings array and return the bound value whether that is a namespace or singleton instance.

use Psr\Container\ContainerInterface;

class Container implements ContainerInterface
{
    public function get($id)
    {
        if ($this->has($id)) {
            return $this->bindings[$id];
        }
        throw new Exception("Container entry not found for: {$id}");
    }

    public function has($id): bool
    {
        return array_key_exists($id, $this->bindings);
    }
}

Retrieving bindings

Using the container its possible to set, get, and update container bindings when required resulting in a very dynamic and powerful way to easily swop out dependencies.

$container->bind(ConfigInterface::class, PHPConfig::class);
$container->get(ConfigInterface::class); 
// returns PHPConfig namespace

$container->bind(ConfigInterface::class, YAMLConfig::class);
$container->get(ConfigInterface::class); 
// returns YAMLConfig namespace

$container->bind(PHPConfig::class, YAMLConfig::class);
$container->get(PHPConfig::class); 
// returns YAMLConfig namespace

Singleton bindings return the same instance every time.

$container->singleton(PHPConfig::class, new PHPConfig());
$container->get(PHPConfig::class); 
// returns singleton PHPConfig instance

$container->get(PHPConfig::class); 
// returns the same singleton PHPConfig instance

Dependency injection

If the container is used to initialize class instances and call class methods, dependencies for both of these can be swopped out for bindings inside the container.

class App
{
    public function __construct(
        protected ConfigInterface $config
    ) {}

    public function handle(ConfigInterface $config) {}
}

$container->bind(ConfigInterface::class, PHPConfig::class);
// App constructor and handle method will receive an instance of PHPConfig

$instance = $container->resolve(App::class); 
// resolves and injects the PHPConfig instance into the constructor

$value = $container->resolveMethod(new App, 'handle');
// resolves and injects the PHPConfig instance into the class method

Resolving dependencies

The container needs 2 methods to create resolved class instances and call class methods with the resolved dependencies.

The logic for these is abstracted into a ClassResolver and MethodResolver inside these methods.

These classes take the container as an argument so they access the container bindings.

class Container implements ContainerInterface
{
    public function resolve(string $namespace, array $args = []): object
    {
        return (new ClassResolver($this, $namespace, $args))->getInstance();
    }

    public function resolveMethod(object $instance, string $method, array $args = [])
    {
        return (new MethodResolver($this, $instance, $method, $args))->getValue();
    }
}

Additional arguments can also be passed into these methods for arguments not resolved from the container.

class App
{
    public function __construct(
        protected ConfigInterface $config,
        protected string $arg1,
        protected string $arg2,
    ) {}

    public function handle(ConfigInterface $config, string $arg1, string $arg2) {}
}

$app = $container->resolve(App::class, [
    'arg1' => 'value1', 
    'arg2' => 'value2'
]); 
$value = $container->resolveMethod($app, 'handle', [
    'arg1' => 'value1', 
    'arg2' => 'value2'
]);
// sets the arg values

Its also possible to pass in class instances as arguments and not resolve these dependencies from the container.

$value = $container->resolveMethod($app, 'handle', [
    'config' => new PHPConfig()
    'arg1' => 'value1', 
    'arg2' => 'value2'
]);

Creating the class resolver

Reflection is a powerful tool in PHP that allows you to inspect classes and functions and "see" what parameters they require before you initialize or call them.

The class resolver is responsible for inspecting the class constructor, getting the parameters, and passing them to the ParametersResolver.

Once they have been resolved by ParametersResolver, a class instance is created and the resolved dependencies are injected.

use Psr\Container\ContainerInterface;
use ReflectionClass;

class ClassResolver
{
    public function __construct(
        protected ContainerInterface $container,
        protected string $namespace,
        protected array $args = []
    ) {
    }

    public function getInstance(): object
    {
        // check for container entry
        if ($this->container->has($this->namespace)) {
            $binding = $this->container->get($this->namespace);

            // return if there is a container instance / singleton
            if (is_object($binding)) {
                return $binding;
            }
            // sets the namespace to the bound container namespace
            $this->namespace = $binding;
        }
        // create a reflection class
        $refClass = new ReflectionClass($this->namespace);

        // get the constructor
        $constructor = $refClass->getConstructor();

        // check constructor exists and is accessible 
        if ($constructor && $constructor->isPublic()) {
            // check constructor has parameters and resolve them
            if (count($constructor->getParameters()) > 0) {
                $argumentResolver = new ParametersResolver(
                    $this->container,
                    $constructor->getParameters(),
                    $this->args
                );
                // resolve the constructor arguments
                $this->args = $argumentResolver->getArguments();
            }
            // create the new instance with the constructor arguments
            return $refClass->newInstanceArgs($this->args);
        }
        // no arguments so create the new instance without calling the constructor
        return $refClass->newInstanceWithoutConstructor();
    }
}

Creating the parameters resolver

The parameters resolver works by traversing through a list of passed function / constructor reflection parameters.

If one of the parameters is a class type, the class is found, initialized and added to the returned arguments to be injected.

Its worth noting this also works recursively. If the parameter class requires injected parameters, these will be resolved and injected before the class is initialized.

use Psr\Container\ContainerInterface;
use ReflectionParameter;

class ParametersResolver
{
    public function __construct(
        protected ContainerInterface $container,
        protected array $parameters,
        protected array $args = []
    ) {
    }

    public function getArguments(): array
    {
        // loop through the parameters
        return array_map(
            function (ReflectionParameter $param) {
                // if an additional arg that was passed in return that value
                if (array_key_exists($param->getName(), $this->args)) {
                    return $this->args[$param->getName()];
                }
                // if the parameter is a class, resolve it and return it
                // otherwise return the default value
                return $param->getType() && !$param->getType()->isBuiltin()
                    ? $this->getClassInstance($param->getType()->getName())
                    : $param->getDefaultValue();
            },
            $this->parameters
        );
    }

    protected function getClassInstance(string $namespace): object
    {
        return (new ClassResolver($this->container, $namespace))->getInstance();
    }
}

Creating the method resolver

The method resolver works similar to the class resolver in that it calls ParametersResolver to get the resolved arguments.

It then calls the class instance method with the resolved dependencies.

use Psr\Container\ContainerInterface;
use ReflectionMethod;

class MethodResolver
{
    public function __construct(
        protected ContainerInterface $container,
        protected object $instance,
        protected string $method,
        protected array $args = []
    ) {
    }

    public function getValue()
    {
        // get the class method reflection class
        $method = new ReflectionMethod(
            $this->instance,
            $this->method
        );
        // find and resolve the method arguments
        $argumentResolver = new ParametersResolver(
            $this->container,
            $method->getParameters(),
            $this->args
        );
        // call the method with the injected arguments
        return $method->invokeArgs(
            $this->instance,
            $argumentResolver->getArguments()
        );
    }
}

In conclusion

Much more complex containers implementations can be created and there is numerous container packages available.

This tutorial should have hopefully explained the basics of IoC containers, reflection in PHP, and why the dependency injection pattern is a powerful tool to create scalable and maintainable applications.

Featured Articles