Building Simple PHP DI Container
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.
- get method: The get method is utilized to resolve a class or retrieve an object from the container.
- 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 thetryResolve
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 ofReflectionClass
. If the class is not instantiable (e.g., an abstract class or an interface), it throws aContainerException
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 usingnewInstance
. - 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
isnull
), it throws aContainerException
indicating that a type hint is missing for the parameter. - If the parameter type is a
ReflectionUnionType
(indicating a union type hint), it throws aContainerException
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 isBookController::class
, which represents the fully qualified class name of theBookController
class. - The second argument to the
set
method is a callback function (a closure) that takes an instance of theContainer
class as an argument. Inside the callback, it retrieves an instance of theBookService
class from the container using$c->get(BookService::class)
, and then uses it to instantiate a newBookController
object, which is returned. - Similarly, a binding is created for the
BookService
class. The callback function retrieves an instance of theBookRepository
class from the container using$c->get(BookRepository::class)
. It then uses the retrievedBookRepository
instance to instantiate a newBookService
object, which is returned. - Finally, a binding is created for the
BookRepository
class. The callback function simply returns a new instance of theBookRepository
class. - The
App
class also provides agetContainer
method, which returns theContainer
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