Building Simple PHP DI Container

Opadaalziede
8 min readMar 25, 2024

--

Dependency Injection is a powerful technique used in software development where an object is provided with its required dependencies, rather than creating them internally. By employing Dependency Injection, objects can be decoupled from their dependencies and easily replaced or modified, promoting flexibility and maintainability within the codebase.

To illustrate the problem that Dependency Injection solves, let’s consider an example of building a simple PHP application that displays a list of books to the user. In our index.php file, we have the following code:

<?php

decalre(strict_types=1);

define('BASE_PATH', __DIR__ . "/../");

require_once BASE_PATH . "vendor/autoload.php";
require_once BASE_PATH . "app/core/helpers.php";


$router = App\Core\Router::getRouter();
$app = new App\Core\App();

require BASE_PATH . "routes.php";

$method = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];

try {
$router->route($method, $uri);
}catch (\Throwable $e) {
echo $e->getMessage() . 'in File: ' . $e->getFile() . 'at Line:' . $e->getLine();
} finally {
exit();
}

Here we’re requiring some autoloading and configuration files and routing the request to the appropriate controller.

Within the routes.php file we’re defining the following route:

<?php

$router->get("/", "App\Controllers\BookController@index");

BookController class:

<?php

namespace App\Controllers;

use App\Services\BookService;

class BookController {

protected BookService $bookService;

public function __construct() {

$this->bookService = new BookService();
}

public function index() {

return $this->bookService->getAll();
}
}

BookService class:

<?php

namespace App\Services;

class BookService
{
protected BookRepository $bookRepository;

public function __construct() {

$this->bookRepository = new BookRepository();
}

public function getAll(): array {

return $this->bookRepository->getAllBooks();
}
}

We observe that the current implementation involves hardcoding the dependencies for the BookService and BookRepository classes within the constructors. Consequently, the responsibility of creating an instance of the BookService class lies within the BookController, while the BookService is responsible for instantiating the BookRepository class. This approach results in tight coupling, making the code more challenging to maintain and test.

To address this issue, we can adopt a more flexible and loosely coupled design by passing the dependencies to the constructors. By doing so, we can decouple the creation of instances from the classes that rely on them.

BookController :

<?php

namespace App\Controllers;

use App\Repositories\BookRepository;
use App\Services\BookService;

class BookController {

public function __construct(protected BookService $bookService)
{
}

public function index() {

return $this->bookService->getAll();
}
}

BookService:

<?php

namespace App\Services;

use App\Repositories\BookRepository;

class BookService
{

public function __construct(protected BookRepository $bookRepository)
{
}

public function getAll(): array {

return $this->bookRepository->getAllBooks();
}
}

Dependency Injection (DI) is indeed a manifestation of the Inversion of Control (IoC) principle. Traditionally, a class would handle its own dependencies and create objects internally. However, with DI, this control flow is inverted, placing the responsibility on the outer code to provide the necessary dependencies to a class.

By adopting DI, we achieve greater flexibility and testability in our codebase. For instance, when testing the BookController class, we can create a mock object of the BookService and pass it to the BookController’s constructor. This allows us to isolate the behavior of the BookController and verify its functionality without relying on the actual BookService implementation.

Moreover, DI enables the utilization of interfaces. Instead of accepting concrete classes as dependencies, we can accept implementations of a shared interface. This approach provides loose coupling between components, as the class only relies on the interface contract rather than specific implementations. Consequently, during runtime, it becomes effortless to swap out different implementations of the interface, promoting modularity and extensibility.

Now, the question is how the controller is instantiated ?

It’s obvious that we need to instantiate an object of the BookController class somewhere in the code.

If we take a look to the Router class into the route method:

public function route(string $method, string $uri): bool {

$result = dataGet($this->routes, $method .".". $uri);

if(!$result) {

throw RouterException::routeNotFoundException($uri);
}

$controller = $result['controller'];
$function = $result['method'];

if(class_exists($controller)) {

$controllerInstance = new $controller();

if(method_exists($controllerInstance, $function)) {

$controllerInstance->$function();
return true;

} else {

throw RouterException::undefinedMethodException($controller, $function);
}
}

return false;
}

In the current scenario, we encounter a challenge when creating an instance of the controller, as hardcoding the dependencies may not be suitable. Different controllers might have varying dependencies or even no dependencies at all. This is where a Dependency Injection (DI) Container becomes relevant.

A DI Container is essentially a class that possesses knowledge about other classes within the application. When we require an object of the BookController class, for example, we can request the DI Container to provide us with that object. The DI Container takes on the responsibility of determining how to resolve the dependencies correctly.

In this example, we will adhere to the PSR-11 Container Interface, which offers a standardized interface that can be implemented by a DI Container.

The ContainerInterface defined by PSR-11 includes two essential methods: get and has . These methods serve specific purposes within the container.

  1. get method: The get method is utilized to resolve a class or retrieve an object from the container.
  2. has method: The has method is employed to check if the container has a binding or registration for a particular class or identifier.

Container class:

<?php


namespace App\Core;


use App\Exceptions\ContainerException;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;

class Container implements ContainerInterface
{
protected $bindings = [];

public function get(string $id)
{
if($this->has($id)) {

$resolved = $this->bindings[$id];

return $resolved($this);
}

return $this->tryResolve($id);
}

public function has(string $id): bool
{
return isset($this->bindings[$id]);
}

}
  • The $bindings property is an array that holds the registered bindings in the container. Each binding is represented by a unique identifier ($id) and a callable resolver function.
  • The get method is used to resolve a class or retrieve an object from the container. It first checks if the container has a binding for the given identifier ($id). If a binding is found, it executes the resolver function and passes the container itself as an argument, allowing the resolver to resolve any dependencies. If no binding is found, it falls back to the tryResolve method.
  • The has method checks if the container has a binding for a given identifier ($id). It simply checks if the identifier exists in the $bindings array.

The set method is not defined within the ContainerInterface and is let to the developers to implement:

public function set(string $id, callable $resolver) {

if($this->has($id)) {

throw ContainerException::bindingAlreadyExistsException($id);
}

$this->bindings[$id] = $resolver;
}
  • The set method is used to register a binding in the container. It takes an $id parameter representing the identifier and a $resolver parameter, which is a callable responsible for resolving the dependency.
  • If the binding doesn’t exist, it adds the $resolver to the $bindings array with $id as the key.

And finally, let’s implement the tryResolve method:

public function tryResolve(string $id) {

$reflectionClass = new \ReflectionClass($id);

if(!$reflectionClass->isInstantiable()) {

throw ContainerException::classIsNotInstantiableException($id);
}

$constructor = $reflectionClass->getConstructor();

if(!$constructor) {

return $reflectionClass->newInstance();
}

$params = $constructor->getParameters();

if(!$params) {

return $reflectionClass->newInstance();
}

$dependencies = [];

foreach ($params as $param) {

$paramName = $param->getName();
$paramType = $param->getType();

if(!$paramType) {
throw ContainerException::missingTypeHintException($paramName);
}

if($paramType instanceof \ReflectionUnionType) {
throw ContainerException::unionTypeHintException($paramName);
}

if($paramType instanceof \ReflectionNamedType && ! $paramType->isBuiltin()) {

array_push($dependencies, $this->get($paramType->getName()));
continue;
}

throw ContainerException::invalidParameterException($id);

}

return $reflectionClass->newInstanceArgs($dependencies);
}
  • The tryResolve method is responsible for resolving a class and its dependencies using the Reflection API. It takes the $id parameter representing the identifier of the class to resolve.
  • It creates a new instance of the ReflectionClass using the $id to obtain information about the class.
  • It then checks if the class is instantiable using the isInstantiable method of ReflectionClass. If the class is not instantiable (e.g., an abstract class or an interface), it throws a ContainerException indicating that the class is not instantiable.
  • Next, it retrieves the constructor of the class using getConstructor. If the class doesn't have a constructor (e.g., no arguments), it simply creates and returns a new instance of the class using newInstance.
  • If the constructor exists, it retrieves the constructor parameters using getParameters.
  • If there are no constructor parameters, it simply creates and returns a new instance of the class using newInstance.
  • It iterates through each parameter and checks for type hints and their corresponding dependencies.
  • It gets the parameter name using $param->getName() and the parameter type using $param->getType().
  • If the parameter doesn’t have a type hint ($paramType is null), it throws a ContainerException indicating that a type hint is missing for the parameter.
  • If the parameter type is a ReflectionUnionType (indicating a union type hint), it throws a ContainerException indicating that union type hints are not supported.
  • If the parameter type is a ReflectionNamedType (indicating a named type hint) and it's not a built-in type, it recursively resolves the dependency by calling $this->get($paramType->getName()) and adds it to the $dependencies array. This allows the container to resolve nested dependencies.
  • If none of the above conditions are met, it throws a ContainerException indicating an invalid parameter.
  • Finally, it creates a new instance of the class using newInstanceArgs($dependencies) to pass the resolved dependencies as constructor arguments.

In our Router class, we can improve the process of creating controller objects by leveraging the capabilities of the DI container. Instead of manually instantiating a controller, we can rely on the container to provide us with a fully formed Controller instance. By doing so, the container takes care of creating the object and resolving any dependencies required by the controller.

if(class_exists($controller)) {

$controllerInstance = $this->container->get($controller);

if(method_exists($controllerInstance, $function)) {

$controllerInstance->$function();
return true;

} else {

throw RouterException::undefinedMethodException($controller, $function);
}
}

Lastly, we introduce the App class, which serves as the central component responsible for managing the container instance and registering the necessary bindings.

<?php


namespace App\Core;


use App\Controllers\BookController;
use App\Repositories\BookRepository;
use App\Services\BookService;

class App
{
private static Container $container;

public function __construct()
{
static::$container = new Container();

static::$container->set(BookController::class, function(Container $c) {

$bookService = $c->get(BookService::class);

return new BookController($bookService);
});

static::$container->set(BookService::class, function(Container $c) {

$bookRepository = $c->get(BookRepository::class);

return new BookService($bookRepository);
});

static::$container->set(BookRepository::class, function(Container $c) {

return new BookRepository();
});

}

public function getContainer(): Container {

return static::$container;
}
}
  • Inside the constructor, a new instance of the Container class is created and assigned to the $container property.
  • The constructor then proceeds to register bindings in the container using the set method of the $container instance.
  • Here, a binding is created for the BookController class. The key used for the binding is BookController::class, which represents the fully qualified class name of the BookController class.
  • The second argument to the set method is a callback function (a closure) that takes an instance of the Container class as an argument. Inside the callback, it retrieves an instance of the BookService class from the container using $c->get(BookService::class), and then uses it to instantiate a new BookController object, which is returned.
  • Similarly, a binding is created for the BookService class. The callback function retrieves an instance of the BookRepository class from the container using $c->get(BookRepository::class). It then uses the retrieved BookRepository instance to instantiate a new BookService object, which is returned.
  • Finally, a binding is created for the BookRepository class. The callback function simply returns a new instance of the BookRepository class.
  • The App class also provides a getContainer method, which returns the Container instance used by the application. It allows other parts of the application to access and use the container.

the App class serves as the entry point for setting up the dependency container and registering bindings for various classes in the application. The container is then made accessible through the getContainer method. With this setup, you can retrieve instances of the registered classes from the container when needed, allowing for dependency injection and decoupling of dependencies within the application.

Conclusion:

Overall, the provided code showcases a straightforward implementation of a Dependency Injection (DI) Container using the PHP Reflection API.

However, it’s important to note that this implementation represents a simplified version of a DI Container and may not cover all advanced features found in more robust DI Container libraries. Depending on the requirements of your application, you may consider using established third-party libraries that provide additional features like autowiring, named parameters, and more extensive configuration options.

Nevertheless, the provided code serves as a solid foundation for understanding the fundamental concepts of DI Containers and can be a starting point for building more sophisticated dependency management systems in PHP applications.

You can find the full implemntation on Github:

https://github.com/OpadaAlzaiede/php-dependency-injection-container

--

--