A Stupidly Simple PHP Dependency Injection Container

I’ve already written at length about dependency injection. And in the months since it’s only proven to be more helpful. But just because I got over some of my hangups about SOLID doesn’t mean I got rid of all of my bad habits.

Particularly the bad habit of deciding that even though there’s a perfectly servicable library I’m already using, I can’t ignore the persistent thought that I can do better.

So I did. I got irked by something and ended up writing my own dependency injection container.

The lay of the land

The way I’m building Smolblog’s core library at the moment separates objects into two categories:

  • Value objects contain strongly-typed information. They are (mostly) read-only, as mutation should only happen in certain places. Any methods in a Value object should be self-contained; they do not call out to other objects, Services, or dependencies. Value objects are state, not code.
  • Service objects perform actions. They can have dependencies on other services and objects and should be given those dependencies at construction. They can act on information in Value objects; those should be given to the Service when the particular method is called. Services should typically not contain data; they should be able to function as singletons (regardless of whether they actually are). Service objects are code, not state.

Having this separation has actually really helped me focus the architecture in Smolblog, and it’s kept me from making any one class too “big.”

Dependency injection containers are classes that store dependencies for other classes and can provide instances of them. Essentially, instead of creating new instances, you get new instances from the container.

Where normal code might create a service like this:

$service = new Service(db: new Database(), fetch: new HttpClient());

Using a dependency injection container, it would look like this:

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

This takes all the responsibility for knowing how to instantiate a service away from the classes throughout the application and centralizes it into one place.

Containers are a common pattern, such that there is a common interface for containers to use: PSR-11. This way, different frameworks and libraries can define containers, and other libraries can use them without having to depend on the specific behavior of specific containers. For a while, I was using Container from The League of Extraordinary Packages as the container for Smolblog.

Until I wasn’t.

Preoccupied with whether I could

Let me state two things first:

  1. I could not have built my own container at the outset. I needed to fully grasp the concept first, and that could only happen by writing my own code against another library.
  2. Nothing in this article is a dig against the League’s Container. I want to be absolutely clear on this. I’m not interested in starting drama or picking fights.

But as I got more and more into using dependency injection, especially in the very specific ways I was using it for Smolblog, I realized how… simple the concept was.

In PHP, every class has a static constant class that is simply a string of the fully-qualified class name:

namespace oddEvan\Example\Simple;

class StupidSimple {}

echo StupidSimple::class;
// oddEvan\Example\Simple\StupidSimple

Passing that string into a container’s get method will typically return an instance of that class.

Now let’s consider the constraints I have for Smolblog:

  1. The only classes with dependencies are Services.
  2. Services should be given all dependencies at construction.
  3. Services should function as singletons.

This makes our container’s logic… actually pretty simple:

  1. Have a configuration of classes and dependencies.
  2. Given a class name, check for an existing instance and skip to step 6 if there is one.
  3. If no instances, retrieve that class' dependencies.
  4. For each class in those dependencies, call step 2 with the dependency’s class.
  5. Use the dependencies to create an instance of the class and store it.
  6. Return the instance to the caller.

…I think we can do this.

Considered whether I should

That’s cool and all, but replacing an established library with my own implementation is not something to be done lightly. A well-built library, like the ones from the League, are well-tested and well-maintained by a group of people. I’m just me.

By rolling my own solution, I’m eschewing the time and effort put into the existing library. Sometimes it can look like “bloat” or “unnecessary” code, but often that code covers edge cases that aren’t immediately obvious. Some of those potential bugs can even be security concerns.

In this specific case, a lot of the code in the League’s container involves different ways to load classes into the container. Because it is a general-purpose library, it has to handle several different scenarios:

  • Singleton classes (return the same instance every time)
  • Multiple classes (return a new instance every time)
  • Set dependencies in the constructor
  • Set dependencies by method calls after construction
  • Store classes by name
  • Store classes by aliases
  • Receive an initial configuration
  • Accept changes at any time
  • Determine if a dependency is another class or a value

With Smolblog’s constraints, this list is a lot shorter:

  • Singleton classes
  • Set dependencies in the constructor
  • Store classes by name
  • Receive an initial configuration
  • Determine if a dependency is another class or a value
  • Uses named arguments

That last point is what tipped me over to writing my own container. In PHP 8, you can now use named arguments. This is a language construct I first saw in Objective-C that Apple carried over into Swift, and understandably so. It makes method calls much more readable, especially if they have many optional parameters. Let’s start with an obtuse function:

make_superhero('Larry', 'Larry-Boy', 'Archibald', 3, false);

With named arguments, not only is it clearer what argument is what, but the order is no longer significant:

make_superhero(
  super_name: 'Larry-Boy',
  num_episodes: 3,
	citizen_name: 'Larry',
  assistant: 'Archibald',
  can_super_size: false,
);

I’ve been using named arguments extensively in Smolblog, and I wanted that flexibility in my container. And wanting that feature is ultimately what let me give myself permission to write my own container. It wasn’t—and isn’t!—enough just to want “less code”; there has to be a reason for me to write my code.

So let’s get to it.

Level 1: it begins

We’ll start with a naive implementation just to get an idea of where we are, a simple configuration and handler.

Let’s set up some pretend services first:

class DatabaseService {
  public function __construct() {
    $this->connection = new DatabaseConnection('db://user:pass@server/db');
  }
  //...
}

class UserService {
  public function __construct(private DatabaseService $db) {}
  //...
}

class UserApiService {
  public function __construct(private UserService $users) {}
  //...
}

For configuration, we’ll create an array of arrays. Each array will contain a class' dependencies, and we’ll key that array to the class' name:

$config = [
  UserApiService::class => [
    'users' => UserService::class,
  ],
  UserService::class => [
    'db' => DatabaseService::class,
  ],
  DatabaseService::class => [],
];

And now, our container:

class Container implements Psr\Container\ContainerInterface {
  private array $instances = [];
  
  public function __construct(private array $config) {}

  public function has(string $id): bool {
    return array_key_exists($id, $this->config);
  }
  
  public function get(string $id) {
    // Check if $id is in the configuration.
    if (!$this->has($id)) { throw new ServiceNotFoundException($id); }
    
    // If we don't already have an instance, create one.
    $this->instances[$id] ??= $this->instantiateService($id);
    
    // Return the instance.
    return $this->instances[$id];
  }
  
  private function instantiateService(string $id) {
    // Get the listed dependencies from the container.
		$args = array_map(
			fn($dependency) => $this->get($dependency),
			$this->config[$id]
		);

		return new $service(...$args);
  }
}

Simple! But these are hardly real-world conditions.

Level 2: Other Parameters

Now let’s say we want to make DatabaseService more resilient. Instead of having a hard-coded database connection string, we’ll pass one into the constructor:

class DatabaseService {
  public function __construct(string $connectionString) {
    $this->connection = new DatabaseConnection($connectionString);
  }
  //...
}

Now we just add that string to our configuration… wait…

$config = [
  //...
  DatabaseService::class => [
    'connectionString' => 'db://user:pass@server/db', // This is ambiguous
  ]
];

Remember that the class constants are just strings. How is our container going to tell the difference between a class string like oddEvan\Thing\DatabaseService and db://user:pass@server/db?

  • We could check class_exists or $this->has() to see if the given string represents a class or a value.
  • We could have some way of tagging an entry as a value.

Right now, I prefer explicit signals over trying to “figure out” a programmer’s intent. So to explicitly tag this as a value, we’ll use a callable (such as an arrow function) that will return the value we want. Let’s revisit our configuration with this:

$config = [
  //...
  DatabaseService::class => [
    'connectionString' => fn() => 'db://user:pass@server/db', // This is clearer.
  ]
];

Then we’ll look for callables in the container:

class Container implements Psr\Container\ContainerInterface {
  //...
  private function instantiateService(string $id) {
    // Get the listed dependencies from the container.
		$args = array_map(
			fn($dependency) =>
      	is_callable($dependency) ?
      		call_user_func($dependency) :
      		$this->get($dependency),
			$this->config[$id]
		);

		return new $service(...$args);
  }
}

Level 3: Interfaces

What about when a class takes an interface as a dependency (which it should)? Let’s add a PSR-18 HTTP client to one of our services:

class UserService {
  public function __construct(
    private DatabaseService $db,
    private \Psr\Http\Client\ClientInterface $http,
  ) {}
  //...
}

Updating the UserService configuration is easy enough since an interface also has a class constant:

$config = [
  //...
  UserService::class => [
    'db' => DatabaseService::class,
    'http' => \Psr\Http\Client\ClientInterface::class,
  ],
];

But now we need to add ClientInterface to our container somehow. We need to have some way to give an implementation in the configuration; otherwise our container will (unsuccessfully) try to instantiate an interface!

Going back to the idea of explicit signals, we actually can use strings here:

$config = [
  //...
  \Psr\Http\Client\ClientInterface::class => MyHttpClient::class,
];

Now we check the type of the class' configuration: if it’s a string, we get that class.

class Container implements Psr\Container\ContainerInterface {
  //...
  private function instantiateService(string $id) {
    $config = $this->config[$id];
    
    if (is_string($config)) {
			// This is an alias.
			return $this->get($config);
		}
    
    //...
  }
}

Note that we are very specifically not checking if $id is an interface. We want to be able to alias any class in here in case we want to replace a particular dependency with a subclass.

We kind of handwaved an implementation of that class. What if we wanted to use something specific?

Level 4: Factories

Let’s say instead of rolling our own HTTP client, we used an off-the-shelf library like Guzzle?

$config = [
  //...
	\Psr\Http\Client\ClientInterface::class => \GuzzleHttp\Client::class,
];

According to the Guzzle docs, a Client only needs a configuration array. We could do this with our existing config structure:

$config = [
  //...
	\GuzzleHttp\Client::class => [
    'config' => fn() => ['connect_timeout' => 30],
  ],
];

And this would work! But there’s a small assumption here that could turn into technical debt.

Remember that our container splats the configuration into the parameters of the class' constructor. If the maintainers of Guzzle ever change the name of the parameter from $config to something else, our container would break. One way to avoid this would be to remove the key from the dependency array, but that still feels fragile to me. What we need is a way to create an instance of Client without assuming it will have the same constraints our services have.

We can do something similar to aliases: provide a callable function that returns the entire object.

$config = [
  //...
	\GuzzleHttp\Client::class =>
  	fn() => new \GuzzleHttp\Client(['connect_timeout' => 30]),
];

Then we check for those in the container:

class Container implements Psr\Container\ContainerInterface {
  //...
  private function instantiateService(string $id) {
    $config = $this->config[$id];
    
    if (is_callable($config)) {
			// The config is a factory function.
			return call_user_func($config);
		}
    
    //...
  }
}

Finishing up

At this point, we’ve hit all the use cases I have for a dependency injection container:

  • Lazy instantiation
  • One instance per class
  • Aliases (replacing one class/interface with another)
  • Dependencies can be other classes or functions returning a value
  • Factory methods to manually create instances

There’s a few places we could go from here. We could use the Reflection API to automatically determine configuration for some simple cases. We could (should!) add more error handling for when the configuration doesn’t match the code. And if you need those features, you can build them! Or just use something off-the-shelf that already does it.

Anyway, here’s our completed configuration and container:

// Service Classes //

class DatabaseService {
  public function __construct(string $connectionString) {
    $this->connection = new DatabaseConnection($connectionString);
  }
  //...
}

class UserService {
  public function __construct(
    private DatabaseService $db,
    private \Psr\Http\Client\ClientInterface $http,
  ) {}
  //...
}

class UserApiService {
  public function __construct(private UserService $users) {}
  //...
}

// Configuration //

$config = [
  UserApiService::class => [
    'users' => UserService::class,
  ],
  UserService::class => [
    'db' => DatabaseService::class,
    'http' => \Psr\Http\Client\ClientInterface::class,
  ],
  DatabaseService::class => [
    'connectionString' => fn() => 'db://user:pass@server/db',
  ],
	\Psr\Http\Client\ClientInterface::class => \GuzzleHttp\Client::class,
	\GuzzleHttp\Client::class =>
  	fn() => new \GuzzleHttp\Client(['connect_timeout' => 30]),
];

// Dependency Injection Container //

class Container implements Psr\Container\ContainerInterface {
  private array $instances = [];
  
  public function __construct(private array $config) {}

  public function has(string $id): bool {
    return array_key_exists($id, $this->config);
  }
  
  public function get(string $id) {
    // Check if $id is in the configuration.
    if (!$this->has($id)) { throw new ServiceNotFoundException($id); }
    
    // If we don't already have an instance, create one.
    $this->instances[$id] ??= $this->instantiateService($id);
    
    // Return the instance.
    return $this->instances[$id];
  }
  
  private function instantiateService(string $id) {
    $config = $this->config[$id];
    
    if (is_callable($config)) {
			// The config is a factory function.
			return call_user_func($config);
		}
    
    if (is_string($config)) {
			// This is an alias.
			return $this->get($config);
		}

    // Get the listed dependencies from the container.
		$args = array_map(
			fn($dependency) =>
      	is_callable($dependency) ?
      		call_user_func($dependency) :
      		$this->get($dependency),
			$config
		);

		return new $service(...$args);
  }
}

I’ll leave you with this last comment. You’ll note that our simple container still adheres to the Psr\Container\ContainerInterface interface. When I’m building a service that needs a container, I’m depending on this interface, not my specific container. The only part of Smolblog that really cares about how the container works is this configuration. And because this configuration is itself so simple, I could adapt it to a different container if and when I need to.

Which is really the whole point of this exercise: loosely couple things together. Using standard interfaces and a dependency injection container means that many of the key libraries Smolblog depends on can be swapped out. And that includes the container itself.

Thanks for reading; I’ll see y’all next time.

Evan Hildreth @oddevan