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
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
Inside the container we have an array of bindings which maps 2 different things.
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;
}
}
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);
}
}
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
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
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'
]);
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();
}
}
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();
}
}
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()
);
}
}
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.