DEV Community

Nad Lambino
Nad Lambino

Posted on

DI Container: What is it and how to create one

Have you ever wondered how does Laravel provide us an instance of a class that we’re injecting on our controllers? Whether it’s an interface, an abstract class, or a concrete class. It feels like a lot of magic is happening, right? Well, it’s all because of Dependency Injection Container.

If you don’t have any idea yet about dependency injection, you can read my article about SOLID Principle with Laravel and go to the last section which is about dependency injection.

Basically, Dependency Injection Container is an object that knows how to instantiate an object and configure all of its dependencies. Whenever we try to inject a class into a controller’s constructor or its method, Laravel’s DI Container tries to resolve and create this object for us, including all of its dependencies. Laravel, as a powerful framework is not just limited to resolving concrete classes but also has a lot of features, such as binding interfaces or abstract classes into concrete class, singleton binding, contextual binding, and many more. In this article, we will try to create our very own DI container with basic functionality, so let's get started.

To create our DI Container, we will be using PHP’s Reflection API. It is a set of built-in classes and functions in PHP that allows us to inspect the structure of classes, methods, properties, and other objects at runtime. Aside from Reflection API, we can also use PSR-11: Container interface — PHP-FIG to have a guide on how we can implement our DI Container. However, I won’t be using it here to keep it short and simple.

Let us first create our Container class and its properties.

<?php

declare(strict_types=1);

class Container
{

  /** Array of [abstract => concrete] bindings */
  private array $bindings = [];

  /** Array of resolved instances */
  private array $resolved = [];

}
Enter fullscreen mode Exit fullscreen mode

In the above code, $bindings property will hold all of our bindings. Such example of binding is whenever we inject a StorageInterface in our controller, we may want it to be an instance of a LocalStorageService or S3StorageService depending on our environment.

<?php

declare(strict_types=1);

class Container
{

  /** ...property declarations here */

  /** Add binding into $bindings array */
  public function bind(string $abstract, string $concrete = null): void
  {
    $concrete = $concrete ?? $abstract;

    if (!class_exists($concrete)) {
      throw new Exception("Non-existing $concrete class");
    }

    $this->bindings[$abstract] = $concrete;
  }
}

/** Example usage */
$container = new Container();
$container->bind(StorageInterface::class, LocalStorageService::class);

/** FileController constructor */
public function __construct(StorageInterface $storage)
{
  /** 
   * Code goes here using the $storage which we expect to be an 
   * instance of LocalStorageService class 
   */
}
Enter fullscreen mode Exit fullscreen mode

Next on our code is the bind method. This method requires a string $abstract class name or interface name that we will inject on our controller, and an optional string $concrete class name that we want to be instantiated when the given abstract class was injected. Note that bind method simply binds a concrete class into an abstract class or interface, it’s not yet resolving or instantiating it.

<?php

declare(strict_types=1);

class Container
{

  /** ...property declarations here */

  /** Add binding into $bindings array */
  public function bind(string $abstract, string $concrete = null): void
  { ... }

  /** Create an instance of the give class */
  public function make(string $abstract, string $concrete = null): mixed
  {
    $concrete = $concrete ?? $abstract;

    if (isset($this->bindings[$abstract])) {
      $class = $this->bindings[$abstract];
    } else {
      $this->bind($abstract, $concrete);
      $class = $concrete;
    }

    /** Don't worry about this method for now */
    return $this->resolve($class);
  }
}

/** Example usage */
$container = new Container();
$container->bind(StorageInterface::class, LocalStorageService::class);

/** Expected to be an instance of LocalStorageService */
$storage = $container->make(StorageInterface::class);
Enter fullscreen mode Exit fullscreen mode

Next method is the make which is responsible for creating an instance of the given abstract or concrete class. If $concrete was not provided, it will use the $abstract parameter as the concrete class. If we already have a binding for this abstract, we will just use its binding, otherwise, we will bind it so that whenever there’s another class or method that is dependent on this abstract along the same request, we can just use this binding. After we get its concrete class, we will then pass it to the resolve method.

<?php

declare(strict_types=1);

class Container
{

  /** ...property declarations here */

  /** Add binding into $bindings array */
  public function bind(string $abstract, string $concrete = null): void
  { ... }

  /** Create an instance of the give class */
  public function make(string $abstract, string $concrete = null): mixed
  { ... }

  /** Resolve the given class and all of its dependencies */
  public function resolve(string $class): mixed
  {
    /** Immediately return a new instance of the class if its already resolved */
    if ($this->resolved[$class]) {
      try {
        return new ($this->resolved[$class]);
      } catch (Throwable) { }
    }

    /** Use ReflectionClass to inspect the given class */
    $reflection = new ReflectionClass($class);
    if (! $reflection->isInstantiable()) {
      throw new Exception("Unable to instantiate a non-instantiable $class");
    }

    /** Create the class constructor and resolve its dependencies */
    $constructor = $reflection->getConstructor();
    $dependencies = $constructor ? $this->resolveDependencies($constructor) : [];

    try {
      /** Return a new instance of the reflected class with its dependencies */
      return $reflection->newInstanceArgs($dependencies);
    } catch (ReflectionException) {
      $dependencyString = implode(', ', $dependencies);
      throw new Exception("Unable to resolve the following dependencies $dependencyString");
    }
  }

  /** 
   * Returns an array of class dependencies 
   * It can be an instantiated class, or the provided default value
   */
  private function resolveDependencies(ReflectionMethod|ReflectionClass $class): array
  {
    $dependencies = [];

    foreach ($class->getParameters() as $param) {
      $type = $param->getType();
      $parameter = $param->getName();

      if ($param->isDefaultValueAvailable()) {
        $dependencies[] = $param->getDefaultValue();
        continue;
      }

      if ($type?->isBuiltin()) {
        throw new Exception("Unable to resolve built in parameter type");
      }

      if ($type === null) {
        throw new Exception("Unable to resolve missing parameter type");
      }

      $name = $type->getName();

      if ($this->resolved[$name]) {
        $dependencies[] = new ($this->resolved[$name]);
        continue;
      }

      $resolved = $this->resolve($name);
      $dependencies[] = $resolved;
      $this->resolved[$name] = $resolved;
    }

    return $dependencies;
  }
}
Enter fullscreen mode Exit fullscreen mode

Lastly, we will have these two methods, resolve and resolveDependencies.

resolve method is the one that is responsible for creating an instance of the given class. If the class was already resolved, it will immediately return that instance. If not, then we will use PHP’s ReflectionClass class to check if it can be instantiated.

Next is to get the constructor of the class from Reflection API. $reflection->getConstructor() returns null when the class doesn’t have a constructor, meaning that it doesn’t have any dependency, so we can just set the $dependencies variable to empty array, otherwise, it returns a ReflectionMethod which we then passed to the resolveDependencies method.

resolveDependencies method is responsible for resolving all of the method’s parameters, in this case, the class constructor. $class->getParameters() returns an array of parameters. We can then inspect each parameter, like its type, name, whether it has default values, get that default value, and check if the type is built in or null if not provided. We can then get the name of its type and use recursion to call the resolve method again to try resolve this parameter.

We now have a working implementation of a DI Container.

<?php

$container = new Container();
$container->bind(StorageInterface::class, LocalStorageService::class);

// Instance of the UploadController injected with LocalStorageService class
$uploadController = $container->make(UploadController::class);
Enter fullscreen mode Exit fullscreen mode

Note that this is a very basic implementation of a DI Container. It does not have a lot of features which is usually offered by powerful frameworks like Laravel. However, I hope that you have learned new things in this article and get the idea of how it works when we use it on our favorite frameworks.

Thank you for reading :)

Top comments (0)