Building a micro dependency container, because why not?
We're currently completely rewriting the Flare backend client packages that run on our clients' servers. The original clients were written more than six years ago, and after more than a hundred releases, countless new features, and many bug fixes, it was time to review the packages.
In this blog post, I'm going to concentrate on the PHP agnostic package. Our Laravel package requires this package, which was written for non-PHP environments like WordPress, Symfony, CraftCMS, or any other PHP application/framework.
In Laravel applications and packages, we can use the almighty dependency container. A container can build PHP objects with dependencies on other objects, create objects based upon bindings provided by the developer, store singletons, and much more!
In our framework-agnostic package, we've refrained from using the Laravel Container since that would add an extra dependency on Laravel, which is weird in a framework-agnostic package. But with the rewrite, we found out that a single container that holds all the package dependencies is quite handy because it makes code a lot more readable and maintainable.
Therefore, let's add a container to the package that isn't the Laravel container. Other packages provide containers, and they work marvelously. But this also introduces an extra dependency, which we don't want.
As an extra requirement, since a lot of code is shared between the Laravel package and the framework-agnostic PHP package, it would be useful to share the bindings for building objects between the packages so that we only need to write them once.
That brings us back to the Laravel container ... software development is complicated. There's one extra feature about the Laravel container we haven't discussed: PSR-11. It is an abstraction written by the PHP-FIG and tries to create a standard interface so that framework components from different vendors can communicate with each other. Laravel's container is implementing the PSR-11 interface, which looks like this:
interface ContainerInterface
{
public function get(string $id);
public function has(string $id): bool;
}
As you can see, it requires two methods to be implemented on a container:
- get a way to retrieve (most of the time) an object from the container
- has a way to check if a binding exists in the container
What if we built our own micro container implementing the ContainerInterface
in our PHP agnostic package? Then, we would have a container in the PHP agnostic package, and since our container implements the same PSR interface, we could swap our container with Laravel's container in the Laravel package.
We could then create a ServiceProvider like this in the PHP agnostic package:
use Illuminate\Contracts\Container\Container as IlluminateContainer;
use Spatie\FlareClient\Support\Container;
class FlareProvider
{
public function __construct(
protected Container|IlluminateContainer $container,
) {
}
public function register(): void
{
// register further bindings
}
}
In a framework-agnostic project, you'll initialize Flare like this:
Flare::make('your-api-key');
Which would internally look like this:
public static function make(
string $apiToken,
): self {
$container = Container::instance();
$provider = new FlareProvider($container);
$provider->register();
}
In Laravel, we'll then have the following ServiceProvider:
class FlareServiceProvider extends ServiceProvider
{
protected FlareProvider $provider;
public function register(): void
{
$provider = new FlareProvider(
$this->app,
);
$provider->register();
}
}
Now, within both the Laravel and framework agnostic package, we'll be able to get dependencies from the container as such:
$this->container->get(ViewExceptionMapper::class)
Binding things
The PSR-11 interface has one problem: It defines whether a binding exists in a container and provides a way to retrieve it. What it doesn't provide is a way to add bindings.
This isn't a problem in the Laravel container since the container supports auto-wiring, which means that an object with dependencies can be automatically resolved by resolving its entire chain of dependencies.
In our homegrown micro container, we don't want to think about auto-wiring; while implementing such a feature isn't that complicated, it adds extra maintenance to a part of the package we didn't even want to build in the beginning.
As an alternative to auto wiring, we could define all our bindings manually in the code. While this initially might seem like a lot of work, since we're working with a package, there aren't that many dependencies, so it won't be that much work. Since the container will only be used by the package, we know that the set of dependencies won't grow unless we add them ourselves. This means we just need to remember to add the bindings, and we're done.
Lastly, auto-wiring is a cool feature, but it makes applications slower since a lot of reflection is required to determine how to construct objects and their dependencies. By manually defining bindings, we'll have the quickest container possible, which is great since we don't want our package to add too much of a performance penalty to our client's applications.
That leaves one question: How should manual bindings be defined? Since our ServiceProvider is shared with a Laravel and framework-agnostic container, let's do it the same way as Laravel.
A binding can be added as such:
$container->bind(ViewExceptionMapper::class, fn() => new ViewExceptionMapper(
$container->get(ViewDataResolver::class)
));
A default binding will be recreated every time it is resolved from the container. In some cases, we want to create a binding and store the object in the container so that the same object is returned the next time the binding needs to be created.
Basically, a singleton thus:
$container->bind(ConfigStore::class, fn() => new ConfigStore(
$config
))
Building our container
Let's take a look at the implementation of the container:
use Psr\Container\ContainerInterface;
class Container implements ContainerInterface
{
/**
* @template T
*
* @param array<class-string<T>, Closure(): T> $definitions
* @param array<class-string<T>, Closure(): T> $singletons
* @param array<class-string<T>, T> $initializedSingletons
*/
public function __construct(
protected array $definitions = [],
protected array $singletons = [],
protected array $initializedSingletons = [],
) {
}
/**
* @template T
*
* @param class-string<T> $class
* @param null|Closure():T $builder
*/
public function singleton(string $class, ?Closure $builder = null): void
{
$this->singletons[$class] = $builder ?? fn () => new $class();
}
/**
* @template T
*
* @param class-string<T> $class
* @param null|Closure():T $builder
*/
public function bind(string $class, ?Closure $builder = null): void
{
$this->definitions[$class] = $builder ?? fn () => new $class();
}
/**
* @template T
*
* @param class-string<T> $id
*
* @return T
*/
public function get(string $id)
{
if (array_key_exists($id, $this->initializedSingletons)) {
return $this->initializedSingletons[$id];
}
if (array_key_exists($id, $this->singletons)) {
return $this->initializedSingletons[$id] = $this->singletons[$id]();
}
if (array_key_exists($id, $this->definitions)) {
return $this->definitions[$id]();
}
throw ContainerEntryNotFoundException::make($id);
}
public function has(string $id): bool
{
return array_key_exists($id, $this->definitions) || array_key_exists($id, $this->singletons);
}
}
That's probably the smallest PHP container out there, but it works!
Since a container is only resolved once per application, it is an excellent case for the singleton pattern. This allows us to access the container in places where passing the initially created container object is cumbersome.
We can quickly add support for a singleton like this:
class Container implements ContainerInterface
{
private static self $instance;
public static function instance(): self
{
return self::$instance ??= new self();
}
/**
* @template T
*
* @param array<class-string<T>, Closure(): T> $definitions
* @param array<class-string<T>, Closure(): T> $singletons
* @param array<class-string<T>, T> $initializedSingletons
*/
protected function __construct(
protected array $definitions = [],
protected array $singletons = [],
protected array $initializedSingletons = [],
) {
}
// The rest of the container code
}
Define agnostic, extend in Laravel
What if we created a binding in the framework-agnostic version of the package and then needed to redefine this binding in the Laravel package?
Well, that won't be a problem since the latest binding always takes precedence over the earlier defined bindings.
We can illustrate this with the following piece of code:
$container->bind(
ViewExceptionMapper::class,
fn() => new ViewExceptionMapper(
$container->get(ViewDataResolver::class)
)
);
$container->bind(
ViewExceptionMapper::class, fn() => new LaravelViewExceptionMapper(
$container->get(ViewDataResolver::class)
)
);
$container->get(ViewExceptionMapper::class); // returns LaravelViewExceptionMapper
This works great! But sometimes, we already have a binding in the framework-agnostic package and want to keep it as is but transform it just a bit to make it work with the Laravel package.
An example of such an object is the Resource
object, a key-value store for the current resource (aka server, OS, network, etc.) on which the code is running.
In the framework agnostic package, we'll define the binding as such:
$container->singleton(
Resource::class,
fn() => Resource::create()->addAttribute(
'host.ip',
gethostbyname(gethostname());
)
);
Then, in the Laravel version of the package, we also want to include the current version of Laravel the code is running on; this can be done by using a native function called extend
:
$container->extend(
Resource::class,
fn(Resource $resource) => $resource->addAttribute(
'laravel.version',
app()->version()
)
);
The next time the Laravel container resolves the Resource
class, not only host.ip
but also laravel.version
will be added. That's neat!
In the end
This micro container will appear in the next major version of the Flare PHP client. As Spatie, we have been considering turning this into a package (of course, we have 😅). This isn't the plan at the moment since it is just a tiny, non-complicated piece of code to make the package work.
But as a young Canadian pop idol once said, never say never.