Building a Simple IoC Container in PHP

Majd Soubh
9 min readDec 28, 2024

--

IoC Container

Modern application often rely on Inversion of Control (IoC) containers to manage dependencies and promote clean, maintainable code, but have you ever wondered how these containers work under the hood? in this article, we’ll walk through the process of building a lightweight IoC container from scratch in PHP.

Inversion of Control (IoC) is a design principle that reverses the traditional flow of control in application development. Instead of classes instantiating their dependencies directly, an external entity like IoC container handles this responsibility..

Project Structure

Project directory structure might look like this:
app/
├── contracts/
│ └── ContainerInterface.php
├── services/
│ └── ServiceContainer.php
src/
└── index.php
vendor/

Make sure you have PHP 7.4+ installed to take advantage of modern features like typed properties and union types.

Setting Up Composer

initialize your project with Composer to handle autoloading. Create a composer.json file in your project root with the following configuration:

{
"name": "maso/service-container",
"description": "A basic IoC (inversion of control) container to manage app dependencies",
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"require": {
"php": "^7.4 || ^8.0"
}
}

Generate Autoload Files

Run the following commands in your terminal:

composer update
composer dump-autoload

Implementing the IoC Container

To follow best practices and allow flexibility, let’s define an interface for the Service Container.

<?php

declare(strict_types=1);

namespace App\Contracts;

interface ContainerInterface
{
/**
* Bind a service or class to the container.
*
* @param string $abstract
* @param callable|string $concrete
* @return void
*/
public function bind(string $abstract, object|callable|string $concrete): void;

/**
* Bind a singleton service or class to the container.
*
* @param string $abstract
* @param callable|string $concrete
* @return void
*/
public function singleton(string $abstract, object|callable|string $concrete): void;

/**
* Resolve a service or class from the container.
*
* @param string $abstract
* @return object
*/
public function resolve(string $abstract): object;
}

Now, let’s implement the IoC container class.

<?php

declare(strict_types=1);

namespace App\Services;

use App\Contracts\ContainerInterface;

class ServiceContainer implements ContainerInterface
{

/**
* Array to store bindings for services.
*
* @var array
*/
private array $bindings = [];
/**
* Array to store singleton instances.
*
* @var array
*/
private array $singletons = [];

public function bind(string $abstract, object|callable|string $concrete): void
{
$this->bindings[$abstract] = $concrete;
}

public function singleton(string $abstract, object|callable|string $concrete): void
{
$this->singletons[$abstract] = $concrete;
}

public function resolve(string $abstract): object
{

// Track resolved services to detect circular dependencies.
static $resolving = [];

if (isset($resolving[$abstract]))
{
throw new \Exception("Circular dependency detected: {$abstract}");
}

// Mark current class as being resolved.
$resolving[$abstract] = true;

// Check if it's singleton and return the existing instance.
if (isset($this->singletons[$abstract]))
{
$this->singletons[$abstract] = $this->resolveBinding($this->singletons[$abstract]);

unset($resolving[$abstract]);

return $this->singletons[$abstract];
}


// Check if it exists in bindings and return the instance.
else if (isset($this->bindings[$abstract]))
{
$instance = $this->resolveBinding($this->bindings[$abstract]);

unset($resolving[$abstract]);

return $instance;
}

// Default: attempt to build the class automatically.
$instance = $this->build($abstract);

// Remove the current class from the list of classes currently being resolved.
unset($resolving[$abstract]);

return $instance;
}

/**
* Automatically resolve and instantiate a class with its dependencies.
*
* @param string $classname
* @return object
* @throws \Exception
*/
protected function build(string $classname): object
{
if (!class_exists($classname))
{
throw new \Exception("Cannot resolve class '{$classname}'.");
}

$reflector = new \ReflectionClass($classname);

// If no constructor, create a new instance.
if (!$reflector->getConstructor())
{
return $reflector->newInstance();
}

// Resolve dependencies for the constructor.
$parameters = $reflector->getConstructor()->getParameters();


// Loop over constructor parameter and create instances of them.
$dependencies = array_map(function ($param) use ($classname)
{
$type = $param->getType();

// Check if parameter is another class and resolve it.
if ($type && !$type->isBuiltin())
{
return $this->resolve($type->getName());
}

// Check if parameter have default value and return it.
if ($param->isDefaultValueAvailable())
{
return $param->getDefaultValue();
}

throw new \Exception("Cannot resolve parameter '{$param->getName()} of the class '{$classname}'.");
}, $parameters);

return $reflector->newInstanceArgs($dependencies);
}

/**
* Resolve a binding to an instance.
*
* @param callable|string|object $binding
* @return object
* @throws \Exception
*/
private function resolveBinding(callable|string|object $binding): object
{
if (is_callable($binding))
{
return $binding($this);
}

if (is_object($binding))
{
return $binding;
}

if (is_string($binding))
{
return $this->build($binding);
}


throw new \Exception("Invalid binding provided.");
}
}

The bind method:
This method used to register a service or a dependency in the container inside the bindings array. When you register a service using bind, a new instance of the service is created every time it is resolved.

The singleton method:
This method registers a service in the container such that only one instance of the service is created and shared across the application. If the service is resolved multiple times, the same instance is returned each time.

Parameters:
$abstract: A service. This is often the class name or an interface.
$concrete: The implementation of the service. It can be:

  • A class name: The container will instantiate this class when resolved.
  • An object instance: The container will directly return the given object.
  • A callable: A closure that specifies how to create the service.

Now, let’s demonstrate the resolve method:

 public function resolve(string $abstract): object
{

// Track resolved services to detect circular dependencies.
static $resolving = [];

if (isset($resolving[$abstract]))
{
throw new \Exception("Circular dependency detected: {$abstract}");
}

// Mark current class as being resolved.
$resolving[$abstract] = true;

// Check if it's singleton and return the existing instance.
if (isset($this->singletons[$abstract]))
{
$this->singletons[$abstract] = $this->resolveBinding($this->singletons[$abstract]);

unset($resolving[$abstract]);

return $this->singletons[$abstract];
}


// Check if it exists in bindings and return the instance.
else if (isset($this->bindings[$abstract]))
{
$instance = $this->resolveBinding($this->bindings[$abstract]);

unset($resolving[$abstract]);

return $instance;
}

// Default: attempt to build the class automatically.
$instance = $this->build($abstract);

// Remove the current class from the list of classes currently being resolved.
unset($resolving[$abstract]);

return $instance;
}

The resolve method is responsible for retrieving or creating instances of services or classes.

Occasionally, your code might encounter a circular dependency, a situation where two or more classes or components rely on each other, forming a loop. This creates a significant problem because the dependencies cannot be resolved leading to a deadlock in the dependency resolution process.

Example of Circular Dependency

class A {
public function __construct(B $b) {}
}

class B {
public function __construct(A $a) {}
}

In this example, class A cannot be instantiated without class B, and class B cannot be instantiated without class A. This leads to an endless loop during resolution.

To prevent circular dependencies, the resolve method includes a static array, $resolving, is used to monitor the resolution process. Before attempting to resolve a class, it will marked to true within the array. If the system detects that a class is already in the process of being resolved, it throws an exception.
Once the class resolution is successfully completed, it will unmarked, ensuring the system can continue resolving other dependencies without any issues.

The next step in resolving a class is to determine whether it exists in the singletons or bindings arrays:

// Check if it's singleton and return the existing instance.
if (isset($this->singletons[$abstract]))
{
$this->singletons[$abstract] = $this->resolveBinding($this->singletons[$abstract]);

unset($resolving[$abstract]);

return $this->singletons[$abstract];
}

// Check if it exists in bindings and return the instance.
else if (isset($this->bindings[$abstract]))
{
$instance = $this->resolveBinding($this->bindings[$abstract]);

unset($resolving[$abstract]);

return $instance;
}

The resolveBinding method is responsible for resolving a given binding into an actual instance of a class. It handles various types of bindings and ensures they are properly instantiated or executed.

 /**
* Resolve a binding to an instance.
*
* @param callable|string|object $binding
* @return object
* @throws \Exception
*/
private function resolveBinding(callable|string|object $binding): object
{
if (is_callable($binding))
{
return $binding($this);
}

if (is_object($binding))
{
return $binding;
}

if (is_string($binding))
{
return $this->build($binding);
}


throw new \Exception("Invalid binding provided.");
}

The resolveBinding method resolves a given binding into an object instance by handling three types of inputs:

  1. Callable: Executes the callable with the service container as an argument and returns the result.
  2. Object: Returns the object as-is, ideal for pre-instantiated singletons.
  3. String: Treats the string as a class name and uses the build method to instantiate it.

If the binding is not a callable, object, or string, it throws an exception, ensuring only valid bindings are processed.

However, when the concrete class we want to resolve is of string type or is not registered in the container, the container attempts to resolve the class automatically using the build method.

/**
* Automatically resolve and instantiate a class with its dependencies.
*
* @param string $classname
* @return object
* @throws \Exception
*/
protected function build(string $classname): object
{
if (!class_exists($classname))
{
throw new \Exception("Cannot resolve class '{$classname}'.");
}

$reflector = new \ReflectionClass($classname);

// If no constructor, create a new instance.
if (!$reflector->getConstructor())
{
return $reflector->newInstance();
}

// Resolve dependencies for the constructor.
$parameters = $reflector->getConstructor()->getParameters();


// Loop over constructor parameter and create instances of them.
$dependencies = array_map(function ($param) use ($classname)
{
$type = $param->getType();

// Check if parameter is another class and resolve it.
if ($type && !$type->isBuiltin())
{
return $this->resolve($type->getName());
}

// Check if parameter have default value and return it.
if ($param->isDefaultValueAvailable())
{
return $param->getDefaultValue();
}

throw new \Exception("Cannot resolve parameter '{$param->getName()} of the class '{$classname}'.");
}, $parameters);

return $reflector->newInstanceArgs($dependencies);
}

The build method leverages PHP’s ReflectionClass to analyze the class metadata and handles constructor-based dependency injection.

The process done through these steps:

  if (!class_exists($classname))
{
throw new \Exception("Cannot resolve class '{$classname}'.");
}
  1. Verify Class Existence
  • Before proceeding, it ensures the class exists using class_exists.
  • If the class does not exist, it throws an exception with a descriptive error message.

2. Initialize Reflection for Class Analysis

$reflector = new \ReflectionClass($classname);

// If no constructor, create a new instance.
if (!$reflector->getConstructor())
{
return $reflector->newInstance();
}

// Resolve dependencies for the constructor.
$parameters = $reflector->getConstructor()->getParameters();

The ReflectionClass is initialized with the class name ($classname).

It then checks whether the class has a constructor by calling getConstructor():

  • If the constructor is absent (i.e., null is returned), it indicates that the class does not require any dependencies to be resolved. In such cases, the method creates a new instance of the class directly using newInstance().
  • If the class has a constructor, the method retrieves its parameters using getConstructor()->getParameters().

However, if the class has a constructor it resolve it’s dependencies, by iterates over them using array_map.

// Loop over constructor parameter and create instances of them.
$dependencies = array_map(function ($param) use ($classname)
{
$type = $param->getType();

// Check if parameter is another class and resolve it.
if ($type && !$type->isBuiltin())
{
return $this->resolve($type->getName());
}

// Check if parameter have default value and return it.
if ($param->isDefaultValueAvailable())
{
return $param->getDefaultValue();
}

throw new \Exception("Cannot resolve parameter '{$param->getName()} of the class '{$classname}'.");
}, $parameters);

For each parameter:

  • Determine Parameter Type:
    If the parameter type is a class (not a built-in type), it is resolved recursively by calling the resolve() method with the class name.
  • Check for Default Values:
    If the parameter has a default value, the method retrieves and uses it.
  • Handle Unresolvable Parameters:
    If the parameter cannot be resolved and lacks a default value, an exception is thrown, highlighting the unresolved parameter and the class it belongs to.

After mapping the constructor parameters to their instantiated dependencies, the method proceeds to create an instance of the class. This is achieved using newInstanceArgs(), which injects the resolved dependencies into the constructor.

return $reflector->newInstanceArgs($dependencies);

Index file

The index file is the entry point for our application.

<?php
// Autoload dependencies and classes using Composer's autoloader
require_once __DIR__ . '/../vendor/autoload.php';

use App\Services\ServiceContainer;

// Initialize the Service Container
$container = new ServiceContainer();

//...

Usage Example

Imagine the following classes with their respective dependencies:

  • Logger logs messages.
  • Database uses Logger to log executed queries.
  • UserService uses Database to interact with the database.

Declare Interfaces

Define contracts for your services to promote flexibility and testability.

// Logger Interface
interface LoggerInterface {
public function log(string $message): void;
}

// Database Interface
interface DatabaseInterface {
public function query(string $sql): mixed;
}

Implement Classes

Provide concrete implementations for the defined interfaces.

// Logger Class: Implements LoggerInterface
class Logger implements LoggerInterface {
public function log(string $message): void {
echo "[LOG]: $message\n";
}
}

// Database Class: Implements DatabaseInterface
class Database implements DatabaseInterface {
private LoggerInterface $logger;

public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}

public function query(string $sql): mixed {
$this->logger->log("Executing query: $sql");
// Simulate database query execution
return "Result of $sql";
}
}

// User Service Class: Depends on DatabaseInterface
class UserService {
private DatabaseInterface $database;

public function __construct(DatabaseInterface $database) {
$this->database = $database;
}

public function getUser(int $id): mixed {
return $this->database->query("SELECT * FROM users WHERE id = $id");
}
}

Extend the Index File

Integrate service bindings into the service container and resolve the UserService class.

// Initialize the Service Container
$container = new ServiceContainer();

// Register service bindings
$container->bind(LoggerInterface::class, function () {
return new Logger();
});

// Register Database as a singleton to ensure only one instance is shared
$container->singleton(DatabaseInterface::class, function ($container) {
return new Database($container->resolve(LoggerInterface::class));
});

// Resolve UserService and use it
$userService = $container->resolve(UserService::class);

// Use the resolved service
echo $userService->getUser(1);

Output

When the script runs, it resolves all dependencies automatically, resulting in the following output:

[LOG]: Executing query: SELECT * FROM users WHERE id = 1
Result of SELECT * FROM users WHERE id = 1

Conclusion

In this article, we explored the power and flexibility of a service container for managing dependencies. By understanding how IoC container operates provides valuable insights into the inner workings of popular frameworks like Laravel and Spring.

For a complete implementation, feel free to explore the GitHub repository:
MajdSoubh/IoC-Container

--

--

Majd Soubh
Majd Soubh

Responses (1)