Martin Joo

« back published by @mmartin_joo on December 28, 2021

Understanding Laravel service containers

Service containers and dependency injection seem like magic when you first start to use them. So, I thought it's a good idea if I write a basic implementation and we try to eliminate the magic.

What is a dependency injection container?

It's a component, a collection of classes that makes this possible:

class PostController
{
    public function __construct(private readonly CreatePost $createPost)
    {
    }

    public function store()
    {
        $this->createPost->execute(...);
    }
}

The CreatePost class just "comes" from somewhere into the controller's constructor. It's injected without the need of manually instantiate one. This is what the service container does. It contains "services", or basically instances of classes, and injects them when you need one of these classes.

However things can get a little complicated, because these dependencies could form a graph (an acyclic one if we are lucky), because the CreatePost class can also have dependencies:

class CreatePost
{
    public function __construct(private readonly ModerateContent $moderateContent, private readonly PostRepository $posts)
}

And of course these classes also can have other dependencies and so on and so on. I think you can smell recursion here.

A manual service container implementation

First, let's start with a manual service container, where the developer needs to register everything before he or she can resolve it from the container.

This is how we're gonna use it:

$container = new ContainerManual();
$container->set(File::class, fn (ContainerManual $c) => new File());
$container->set(Logger::class, fn (ContainerManual $c) => new Logger($c->get(File::class)));
$container->set(ExampleService::class, fn (ContainerManual $c) => new ExampleService($c->get(Logger::class)));

$service = $container->get(ExampleService::class);
$service->create();

We register every class one-by-one and as the second parameter we pass a factory function:

  • The File class has no dependencies so the factory function returns a new instance.
  • The Logger class contains a File as a dependency. In this case we ask the Container to resolve an instance of the File class.
  • The same happens with the ExampleService class.

This is how we can implement such a container:

class ContainerManual
{
    private array $bindings;

    public function set(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = $factory;
    }

    public function get(string $abstract): mixed
    {
        return $this->bindings[$abstract]($this);
    }
}

Yes, that's a very basic, fully functional dependency injection container. It's lame, but it works. Can you spot the magic? I give you a hint:

return $this->bindings[$abstract]($this);

The fact that the factory function gets the $this parameter makes it recursive. Let's what happens when we want to resolve an ExampleService:

// This is the usage
$container->get(ExampleService::class);

// It calls the factory registered for ExampleService:
new ExampleService($c->get(Logger::class));

// It calls the factory registered for Logger:
new Logger($c->get(File::class));

// It calls the factory registered for File:
// And we hit the base case! No more recursive calls, just returns new File()
new File();

// Now we can return the Logger
new Logger(new File());

// ...and the ExampleService
new ExampleService(new Logger(new File()));

That's great, but you obviously don't want to use a service container that requires you to register every one of your class... Let's try to automate it!

An autowired service container implementation

This is where the real magic happens. First, let's see what we want:

$container = new ContainerAuto();
$service = $container->get(ExampleService::class);
$service->create();

That's it, no registration just resolving classes. Let's think about how we can implement such magic:

  1. We need to get the constructor of the ExampleService.
  2. We want the parameters from it.
  3. We also need to know the types.
  4. And we need to instantiate each of the parameters. Of course these parameters can have dependencies as well, so this is the recursive part. But since we know that the parameter is a Logger we can call the Container::get() with the Logger class.

Fortunately we can use PHP Reflection the get the constructor and the parameters.

Get the contrustor:

$constructor = (new ReflectionClass($abstract))->getConstructor();

It returns a ReflectionMethod class that has a getParameters() method.

Get the constructor parameters:

$parameters = $constructor->getParameters();

It returns an ReflectionParameter array. The ReflectionParameter has a method called getType() which has a method called getName(). With these two function we can get the fully qualified namespace of a parameter:

$dependencies = array_map(
    fn (ReflectionParameter $parameter) => $parameter->getType()->getName(),
    $parameters
);

We map each ReflectionParameter object into a string. A string that holds a class name.

The last part is the recursion:

$resolvedDependencies = array_map(
    fn (string $dependency) => $this->get($dependency),
    $dependencies
);

We call the Container::get() with each class name and try to resolve them. Since it's a recursive method we need a base case. If you remember in the manual example the base case was the File class that had no dependencies so we can return it. The same applies here. If we have no constructor or the constructor has no parameters we can return it.

Putting everything together, this a basic but working dependency injection container:

public function get(string $abstract)
{
    $constructor = (new ReflectionClass($abstract))
        ->getConstructor();

    if ($constructor === null) {
        return new $abstract;
    }

    $parameters = $constructor->getParameters();
    if (count($parameters) === 0) {
        return new $abstract;
    }

    $dependencies = array_map(
        fn (ReflectionParameter $parameter) => $parameter->getType()->getName(),
        $parameters
    );

    $resolvedDependencies = array_map(
        fn (string $dependency) => $this->get($dependency),
        $dependencies
    );

    return new $abstract(...$resolvedDependencies);
}

The base cases are logically the same, but you know a class can have a constructor without parameters, and a class can just omit the constructor completely. At the end of the method we are creating a new instance from the $abstract with the resolved dependencies.

Of course we can still support manual registration of services:

class ContainerAuto
{
    private array $bindings;

    public function set(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = $factory;
    }

    public function get(string $abstract)
    {
        if (isset($this->bindings[$abstract])) {
            return $this->bindings[$abstract]($this);
        }

        // The rest is the same as above...
    }
}

You can check out the full example on Github.

As you can see it's not so much magic as it first seems. Basically it's just Reflection and recursion used in a smart way. Of course the Laravel Container is more complicated but the basics are the same. It's more complicated because it supports a lot of other features as well.