Build Your Own Service Container in PHP - Minimal Container

php
Table of Contents

In this mini series, you'll learn how to build your own service container for dependency injection in PHP. I'll start with the simplest PSR-11 compliant container and then add various features until we have a powerful, general purpose container.

A service container is a PHP object that is responsible for the instatiation of other objects. You tell the container how to construct the object, then when you need an instance of it in your program, you ask for one.

PSR-11 is a document that specifies a common interface for service containers. It also defines two Exception interfaces that a container must throw to be compliant.

The interface itself only defines two methods:

interface ContainerInterface
{
    /**
     * Finds an entry of the container by its identifier and returns it.
     *
     * @param string $id Identifier of the entry to look for.
     *
     * @throws NotFoundExceptionInterface  No entry was found for **this** identifier.
     * @throws ContainerExceptionInterface Error while retrieving the entry.
     *
     * @return mixed Entry.
     */
    public function get(string $id);

    /**
     * Returns true if the container can return an entry for the given identifier.
     * Returns false otherwise.
     *
     * `has($id)` returning true does not mean that `get($id)` will not throw an exception.
     * It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`.
     *
     * @param string $id Identifier of the entry to look for.
     *
     * @return bool
     */
    public function has(string $id): bool;
}

The interface is simple on purpose. By only requiring a container to have these 2 methods, it does not dictate how the services are bound to the container in the first place.

Let's start by installing the psr/container package that provides ContainerInterface and creating our own Container class that implements that interface.

composer require psr/container
use Psr\Container\ContainerInterface;

class Container implements ContainerInterface
{
    public function get(string $id): mixed
    {
        // ?
    }

    public function has(string $id): bool
    {
        // ?
    }
}

We want to "bind" some services to the container &mdash so a suitable method name would be bind(). This method needs to accept a string $id and, for now, an object $service.

We can store the bindings in an array on the container.

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

    public function bind(string $id, object $service): void
    {
        $this->bindings[$id] = $service;
    }
}

Now the get() and has() methods can read from $bindings.

class Container implements ContainerInterface
{
    // ...

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

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

The ContainerInterface contains a couple of DocBlock comments with @throws annotations. For the Container class to be truly PSR-11 compliant, we need to make sure that we throw an Exception that implements the NotFoundExceptionInterface interface when trying to call get() with a non-existent $id.

use Psr\Container\NotFoundExceptionInterface;
use Exception;

class ServiceNotFoundException extends Exception implements NotFoundExceptionInterface
{
    // ...
}
class Container implements ContainerInterface
{
    // ...

    public function get(string $id): mixed
    {
        if (! $this->has($id)) {
            throw new ServiceNotFoundException($id);
        }

        return $this->bindings[$id];
    }
}

PSR-11 also defines a ContainerExceptionInterface, stating that Exceptions thrown directly by the container should implement it. The NotFoundExceptionInterface already extends that interface so there's nothing else to do.

We can write some Pest tests for the Container class.

it('can be constructed', function () {
    expect(new Container)->toBeInstanceOf(Container::class);
});

it('can bind and retrieve services', function () {
    $container = new Container;
    $container->bind('container', $container);

    expect($container->has('container'))->toBeTrue();
    expect($container->get('container'))->toBeInstanceOf(Container::class)->toBe($container);
});

it('throws a ServiceNotFoundException when trying to retrieve a non-existent service', function () {
    $container = new Container;

    expect(fn () => $container->get('foo'))
        ->toThrow(ServiceNotFoundException::class);
});

And like that, we have a PSR-11 compliant service container.

If you want to see the code for this, you can find it here on GitHub.

Enjoyed this post or found it useful? Please consider sharing it on Twitter.