Creating your own dependency injection container in PHP
Published by Matthew Daly at 2nd February 2019 8:45 pm
Dependency injection can be a difficult concept to understand in the early stages. Even when you're using it all the time, it can often seem like magic. However, it's really not all that complicated once you actually get into the nuts and bolts of it, and building your own container is a good way to learn more about how it works and how to use it.
In this tutorial, I'll walk you through creating a simple, minimal dependency injection container, using PHPSpec as part of a TDD workflow. While the end result isn't necessarily something I'd be happy using in a production environment, it's sufficient to understand the basic concept and make it feel less like a black box. Our container will be called Ernie (if you want to know why, it's a reference to a 90's era video game that had a character based on Eric Cantona called Ernie Container).
The first thing we need to do is set up our dependencies. Our container will implement PSR-11, so we need to include the interface that defines that. We'll also use PHP CodeSniffer to ensure code quality, and PHPSpec for testing. Your composer.json
should look something like this:
1{2 "name": "matthewbdaly/ernie",3 "description": "Simple DI container",4 "type": "library",5 "require-dev": {6 "squizlabs/php_codesniffer": "^3.3",7 "phpspec/phpspec": "^5.0",8 "psr/container": "^1.0"9 },10 "license": "MIT",11 "authors": [12 {13 "name": "Matthew Daly",14 "email": "450801+matthewbdaly@users.noreply.github.com"15 }16 ],17 "require": {},18 "autoload": {19 "psr-4": {20 "Matthewbdaly\\Ernie\\": "src/"21 }22 }23}
We also need to put this in our phpspec.yml
file:
1suites:2 test_suite:3 namespace: Matthewbdaly\Ernie4 psr4_prefix: Matthewbdaly\Ernie
With that done, we can start working on our implementation.
Creating the exceptions
The PSR-11 specification defines two interfaces for exceptions, which we will implement before actually moving on to the container itself. The first of these is Psr\Container\ContainerExceptionInterface
. Run the following command to create a basic spec for the exception:
$ vendor/bin/phpspec desc Matthewbdaly/Ernie/Exceptions/ContainerException
The generated specification for it at spec/Exceptions/ContainerExceptionSpec.php
will look something like this:
1<?php23namespace spec\Matthewbdaly\Ernie;45use Matthewbdaly\Ernie\ContainerException;6use PhpSpec\ObjectBehavior;7use Prophecy\Argument;89class ContainerExceptionSpec extends ObjectBehavior10{11 function it_is_initializable()12 {13 $this->shouldHaveType(ContainerException::class);14 }15}
This is not sufficient for our needs. Our exception must also implement two interfaces:
Throwable
Psr\Container\ContainerExceptionInterface
The former can be resolved by inheriting from Exception
, while the latter doesn't require any additional methods. Let's expand our spec to check for these:
1<?php23namespace spec\Matthewbdaly\Ernie\Exceptions;45use Matthewbdaly\Ernie\Exceptions\ContainerException;6use PhpSpec\ObjectBehavior;7use Prophecy\Argument;89class ContainerExceptionSpec extends ObjectBehavior10{11 function it_is_initializable()12 {13 $this->shouldHaveType(ContainerException::class);14 }1516 function it_implements_interface()17 {18 $this->shouldImplement('Psr\Container\ContainerExceptionInterface');19 }2021 function it_implements_throwable()22 {23 $this->shouldImplement('Throwable');24 }25}
Now run the spec and PHPSpec will generate the boilerplate exception for you:
1$ vendor/bin/phpspec run2Matthewbdaly/Ernie/Exceptions/ContainerException3 11 - it is initializable4 class Matthewbdaly\Ernie\Exceptions\ContainerException does not exist.56Matthewbdaly/Ernie/Exceptions/ContainerException7 16 - it implements interface8 class Matthewbdaly\Ernie\Exceptions\ContainerException does not exist.910Matthewbdaly/Ernie/Exceptions/ContainerException11 21 - it implements throwable12 class Matthewbdaly\Ernie\Exceptions\ContainerException does not exist.1314 100% 3151 specs163 examples (3 broken)1723ms181920 Do you want me to create `Matthewbdaly\Ernie\Exceptions\ContainerException`21 for you?22 [Y/n]23y24Class Matthewbdaly\Ernie\Exceptions\ContainerException created in /home/matthew/Projects/ernie-clone/src/Exceptions/ContainerException.php.2526Matthewbdaly/Ernie/Exceptions/ContainerException27 16 - it implements interface28 expected an instance of Psr\Container\ContainerExceptionInterface, but got29 [obj:Matthewbdaly\Ernie\Exceptions\ContainerException].3031Matthewbdaly/Ernie/Exceptions/ContainerException32 21 - it implements throwable33 expected an instance of Throwable, but got34 [obj:Matthewbdaly\Ernie\Exceptions\ContainerException].3536 33% 66% 3371 specs383 examples (1 passed, 2 failed)3936ms
It's failing, but we expect that. We need to update our exception to extend the base PHP exception, and implement Psr\Container\ContainerExceptionInterface
. Let's do that now:
1<?php23namespace Matthewbdaly\Ernie\Exceptions;45use Psr\Container\ContainerExceptionInterface;6use Exception;78class ContainerException extends Exception implements ContainerExceptionInterface9{10}
Let's re-run the spec:
1$ vendor/bin/phpspec run2 100% 331 specs43 examples (3 passed)524ms
The second exception we need to implement is Psr\Container\NotFoundExceptionInterface
and it's a similar story. Run the following command to create the spec:
$ vendor/bin/phpspec desc Matthewbdaly/Ernie/Exceptions/NotFoundException
Again, the spec needs to be amended to verify that it's a throwable and implements the required interface:
1<?php23namespace spec\Matthewbdaly\Ernie\Exceptions;45use Matthewbdaly\Ernie\Exceptions\NotFoundException;6use PhpSpec\ObjectBehavior;7use Prophecy\Argument;89class NotFoundExceptionSpec extends ObjectBehavior10{11 function it_is_initializable()12 {13 $this->shouldHaveType(NotFoundException::class);14 }1516 function it_implements_interface()17 {18 $this->shouldImplement('Psr\Container\NotFoundExceptionInterface');19 }2021 function it_implements_throwable()22 {23 $this->shouldImplement('Throwable');24 }25}
For the sake of brevity I've left out the output, but if you run vendor/bin/phpspec run
you'll see it fail due to the fact that the generated class doesn't implement the required interfaces. Amend src/Exceptions/NotFoundException
as follows:
1<?php23namespace Matthewbdaly\Ernie\Exceptions;45use Psr\Container\NotFoundExceptionInterface;6use Exception;78class NotFoundException extends Exception implements NotFoundExceptionInterface9{10}
Running vendor/bin/phpspec run
should now see it pass. Now let's move on to the container class...
Building the container
Run the following command to create the container spec:
$ vendor/bin/phpspec desc Matthewbdaly/Ernie/Container
However, the default generated spec isn't sufficient. We need to check it implements the required interface:
1<?php23namespace spec\Matthewbdaly\Ernie;45use Matthewbdaly\Ernie\Container;6use PhpSpec\ObjectBehavior;7use Prophecy\Argument;89class ContainerSpec extends ObjectBehavior10{11 function it_is_initializable()12 {13 $this->shouldHaveType(Container::class);14 }1516 function it_implements_interface()17 {18 $this->shouldImplement('Psr\Container\ContainerInterface');19 }20}
Now, if we run PHPSpec, we'll generate our class:
1$ vendor/bin/phpspec run2Matthewbdaly/Ernie/Container3 11 - it is initializable4 class Matthewbdaly\Ernie\Container does not exist.56Matthewbdaly/Ernie/Container7 16 - it implements interface8 class Matthewbdaly\Ernie\Container does not exist.910 75% 25% 8113 specs128 examples (6 passed, 2 broken)13404ms141516 Do you want me to create `Matthewbdaly\Ernie\Container` for you?17 [Y/n]18y19Class Matthewbdaly\Ernie\Container created in /home/matthew/Projects/ernie-clone/src/Container.php.2021Matthewbdaly/Ernie/Container22 16 - it implements interface23 expected an instance of Psr\Container\ContainerInterface, but got24 [obj:Matthewbdaly\Ernie\Container].2526 87% 12% 8273 specs288 examples (7 passed, 1 failed)2940ms
Now, as we can see, this class doesn't implement the interface. Let's remedy that:
1<?php23namespace Matthewbdaly\Ernie;45use Psr\Container\ContainerInterface;67class Container implements ContainerInterface8{9}
Now, if we run the tests, they should fail because the class needs to add the required methods:
1$ vendor/bin/phpspec run2✘ Fatal error happened while executing the following3 it is initializable4 Class Matthewbdaly\Ernie\Container contains 2 abstract methods and must therefore be declared abstract or implement the remaining methods (Psr\Container\ContainerInterface::get, Psr\Container\ContainerInterface::has) in /home/matthew/Projects/ernie-clone/src/Container.php on line 7
If you use an editor or IDE that allows you to implement an interface automatically, you can run it to add the required methods. I use PHPActor with Neovim, and used the option in the Transform menu to implement the contract:
1<?php23namespace Matthewbdaly\Ernie;45use Psr\Container\ContainerInterface;67class Container implements ContainerInterface8{9 /**10 * {@inheritDoc}11 */12 public function get($id)13 {14 }1516 /**17 * {@inheritDoc}18 */19 public function has($id)20 {21 }22}
Running vendor/bin/phpspec run
should now make the spec pass, but the methods don't actually do anything yet. If you read the spec for PSR-11, you'll see that has()
returns a boolean to indicate whether a class can be instantiated or not, while get()
will either return an instance of the specified class, or throw an exception. We will add specs that check that built-in classes can be returned by both, and unknown classes display the expected behaviour. We'll do both at once, because in both cases, the functionality to actually resolve the required class will be deferred to a single resolver method, and these methods will not do all that much as a result:
1 function it_has_simple_classes()2 {3 $this->has('DateTime')->shouldReturn(true);4 }56 function it_does_not_have_unknown_classes()7 {8 $this->has('UnknownClass')->shouldReturn(false);9 }1011 function it_can_get_simple_classes()12 {13 $this->get('DateTime')->shouldReturnAnInstanceOf('DateTime');14 }1516 function it_returns_not_found_exception_if_class_cannot_be_found()17 {18 $this->shouldThrow('Matthewbdaly\Ernie\Exceptions\NotFoundException')19 ->duringGet('UnknownClass');20 }
These tests verify that:
has()
returnstrue
when called with the always-presentDateTime
classhas()
returnsfalse
for the undefinedUnknownClass
get()
successfully instantiates an instance ofDateTime
get()
throws an exception if you try to instantiate the undefinedUnknownClass
Running the specs will raise errors:
1$ vendor/bin/phpspec run2Matthewbdaly/Ernie/Container3 21 - it has simple classes4 expected true, but got null.56Matthewbdaly/Ernie/Container7 26 - it does not have unknown classes8 expected false, but got null.910Matthewbdaly/Ernie/Container11 31 - it can get simple classes12 expected an instance of DateTime, but got null.1314Matthewbdaly/Ernie/Container15 36 - it returns not found exception if class cannot be found16 expected to get exception / throwable, none got.1718 66% 33% 12193 specs2012 examples (8 passed, 4 failed)2198ms
Let's populate these empty methods:
1<?php23namespace Matthewbdaly\Ernie;45use Psr\Container\ContainerInterface;6use Matthewbdaly\Ernie\Exceptions\NotFoundException;7use ReflectionClass;8use ReflectionException;910class Container implements ContainerInterface11{12 /**13 * {@inheritDoc}14 */15 public function get($id)16 {17 $item = $this->resolve($id);18 return $this->getInstance($item);19 }2021 /**22 * {@inheritDoc}23 */24 public function has($id)25 {26 try {27 $item = $this->resolve($id);28 } catch (NotFoundException $e) {29 return false;30 }31 return $item->isInstantiable();32 }3334 private function resolve($id)35 {36 try {37 return (new ReflectionClass($id));38 } catch (ReflectionException $e) {39 throw new NotFoundException($e->getMessage(), $e->getCode(), $e);40 }41 }4243 private function getInstance(ReflectionClass $item)44 {45 return $item->newInstance();46 }47}
As you can see, both the has()
and get()
methods need to resolve a string ID to an actual class, so that common functionality is stored in a private method called resolve()
. This uses the PHP Reflection API to resolve the class name to an actual class. We pass the string ID into a constructor of ReflectionClass
, and the resolve()
method will either return the created instance of ReflectionClass
, or throw an exception.
For the uninitiated, ReflectionClass
allows you to reflect on the object whose fully qualified class name is passed to the constructor, in order to interact with that class programmatically. The methods we will use include:
isInstantiable
- confirms whether or not the class can be instantiated (for instance, traits and abstract classes can't)newInstance
- creates a new instance of the item in question, as long as it has no dependencies in the constructornewInstanceArgs
- creates a new instance, using the arguments passed ingetConstructor
- allows you to get information about the constructor
The Reflection API is pretty comprehensive, and I would recommend reading the documentation linked to above if you want to know more.
For the has()
method, we check that the resolved class is instantiable, and return the result of that. For the get()
method, we use getInstance()
to instantiate the item and return that, throwing an exception if that fails.
Registering objects
In its current state, the container doesn't allow you to set an item. To be useful, we need to be able to specify that an interface or string should be resolved to a given class, or for cases where we need to pass in scalar parameters, such as a database object, to specify how a concrete instance of that class should be instantiated. To that end, we'll create a new set()
public method that will allow a dependency to be set. Here are the revised specs including this:
1<?php23namespace spec\Matthewbdaly\Ernie;45use Matthewbdaly\Ernie\Container;6use PhpSpec\ObjectBehavior;7use Prophecy\Argument;8use DateTime;910class ContainerSpec extends ObjectBehavior11{12 function it_is_initializable()13 {14 $this->shouldHaveType(Container::class);15 }1617 function it_implements_interface()18 {19 $this->shouldImplement('Psr\Container\ContainerInterface');20 }2122 function it_has_simple_classes()23 {24 $this->has('DateTime')->shouldReturn(true);25 }2627 function it_does_not_have_unknown_classes()28 {29 $this->has('UnknownClass')->shouldReturn(false);30 }3132 function it_can_get_simple_classes()33 {34 $this->get('DateTime')->shouldReturnAnInstanceOf('DateTime');35 }3637 function it_returns_not_found_exception_if_class_cannot_be_found()38 {39 $this->shouldThrow('Matthewbdaly\Ernie\Exceptions\NotFoundException')40 ->duringGet('UnknownClass');41 }4243 function it_can_register_dependencies()44 {45 $toResolve = new class {46 };47 $this->set('Foo\Bar', $toResolve)->shouldReturn($this);48 }4950 function it_can_resolve_registered_dependencies()51 {52 $toResolve = new class {53 };54 $this->set('Foo\Bar', $toResolve);55 $this->get('Foo\Bar')->shouldReturnAnInstanceOf($toResolve);56 }5758 function it_can_resolve_registered_invokable()59 {60 $toResolve = new class {61 public function __invoke() {62 return new DateTime;63 }64 };65 $this->set('Foo\Bar', $toResolve);66 $this->get('Foo\Bar')->shouldReturnAnInstanceOf('DateTime');67 }6869 function it_can_resolve_registered_callable()70 {71 $toResolve = function () {72 return new DateTime;73 };74 $this->set('Foo\Bar', $toResolve);75 $this->get('Foo\Bar')->shouldReturnAnInstanceOf('DateTime');76 }7778 function it_can_resolve_if_registered_dependencies_instantiable()79 {80 $toResolve = new class {81 };82 $this->set('Foo\Bar', $toResolve);83 $this->has('Foo\Bar')->shouldReturn(true);84 }85}
This needs to handle quite a few scenarios, so there are several tests we have in place. These verify that:
- The
set()
method returns an instance of the container class, to allow for method chaining - When a dependency is set, calling
get()
returns an instance of that class - When a concrete class that has the
__invoke()
magic method set is passed toset()
, it is invoked and the response returned. - When the value passed through is a callback, the callback is resolved and the response returned
- When a dependency is set, calling
has()
for it returns the right value
Note that we use anonymous classes for testing - I've written about these before and they're very useful in this context because they allow us to create a simple class inline for testing purposes.
Running the specs should result in us being prompted to generate the set()
method, and failing afterwards:
1$ vendor/bin/phpspec run2Matthewbdaly/Ernie/Container3 42 - it can register dependencies4 method Matthewbdaly\Ernie\Container::set not found.56Matthewbdaly/Ernie/Container7 49 - it can resolve registered dependencies8 method Matthewbdaly\Ernie\Container::set not found.910Matthewbdaly/Ernie/Container11 57 - it can resolve registered invokable12 method Matthewbdaly\Ernie\Container::set not found.1314Matthewbdaly/Ernie/Container15 68 - it can resolve registered callable16 method Matthewbdaly\Ernie\Container::set not found.1718Matthewbdaly/Ernie/Container19 77 - it can resolve if registered dependencies instantiable20 method Matthewbdaly\Ernie\Container::set not found.2122 70% 29% 17233 specs2417 examples (12 passed, 5 broken)25316ms2627 Do you want me to create `Matthewbdaly\Ernie\Container::set()` for you?28 [Y/n]29y30 Method Matthewbdaly\Ernie\Container::set() has been created.3132Matthewbdaly/Ernie/Container33 42 - it can register dependencies34 expected [obj:Matthewbdaly\Ernie\Container], but got null.3536Matthewbdaly/Ernie/Container37 49 - it can resolve registered dependencies38 exception [exc:Matthewbdaly\Ernie\Exceptions\NotFoundException("Class Foo\Bar does not exist")] has been thrown.3940Matthewbdaly/Ernie/Container41 57 - it can resolve registered invokable42 exception [exc:Matthewbdaly\Ernie\Exceptions\NotFoundException("Class Foo\Bar does not exist")] has been thrown.4344Matthewbdaly/Ernie/Container45 68 - it can resolve registered callable46 exception [exc:Matthewbdaly\Ernie\Exceptions\NotFoundException("Class Foo\Bar does not exist")] has been thrown.4748Matthewbdaly/Ernie/Container49 77 - it can resolve if registered dependencies instantiable50 expected true, but got false.5152 70% 11% 17% 17533 specs5417 examples (12 passed, 2 failed, 3 broken)5590ms
First, we need to set up the set()
method properly, and define a property to contain the stored services:
1 private $services = [];23 public function set(string $key, $value)4 {5 $this->services[$key] = $value;6 return $this;7 }
This fixes the first spec, but the resolver needs to be amended to handle cases where the ID is set manually:
1 private function resolve($id)2 {3 try {4 $name = $id;5 if (isset($this->services[$id])) {6 $name = $this->services[$id];7 if (is_callable($name)) {8 return $name();9 }10 }11 return (new ReflectionClass($name));12 } catch (ReflectionException $e) {13 throw new NotFoundException($e->getMessage(), $e->getCode(), $e);14 }15 }
This will allow us to resolve classes set with set()
. However, we also want to resolve any callables, such as callbacks or classes that implement the __invoke()
magic method, which means that sometimes resolve()
will return the result of the callable instead of an instance of ReflectionClass
. Under those circumstances we should return the item directly:
1 public function get($id)2 {3 $item = $this->resolve($id);4 if (!($item instanceof ReflectionClass)) {5 return $item;6 }7 return $this->getInstance($item);8 }
Note that because the __invoke()
method is automatically called in any concrete class specified in the second argument to set()
, it's only possible to resolve classes that define an __invoke()
method if they are passed in as string representations. The following PsySh session should make it clear what this means:
1>>> use Matthewbdaly\Ernie\Container;2>>> $c = new Container;3=> Matthewbdaly\Ernie\Container {#2307}4>>> class TestClass { public function __invoke() { return "Called"; }}5>>> $c->get('TestClass');6=> TestClass {#2319}7>>> $c->set('Foo\Bar', 'TestClass');8=> Matthewbdaly\Ernie\Container {#2307}9>>> $c->get('Foo\Bar');10=> TestClass {#2309}11>>> $c->set('Foo\Bar', new TestClass);12=> Matthewbdaly\Ernie\Container {#2307}13>>> $c->get('Foo\Bar');14=> "Called"
As you can see, if we pass in the fully qualified class name of a class that defines an __invoke()
method, it can be resolved as expected. However, if we pass a concrete instance of it to set()
, it will be called and will return the response from that. This may not be the behaviour you want for your own container.
According to this issue on the PHP League's Container implementation, it was also an issue for them, so seeing as this is just a toy example I'm not going to lose any sleep over it. Just something to be aware of if you use this post as the basis for writing your own container.
Resolving dependencies
One thing is missing from our container. Right now it should be able to instantiate pretty much any class that has no dependencies, but these are quite firmly in the minority. To be useful, a container should be able to resolve all of the dependencies for a class automatically.
Let's add a spec for that:
1 function it_can_resolve_dependencies()2 {3 $toResolve = get_class(new class(new DateTime) {4 public $datetime;5 public function __construct(DateTime $datetime)6 {7 $this->datetime = $datetime;8 }9 });10 $this->set('Foo\Bar', $toResolve);11 $this->get('Foo\Bar')->shouldReturnAnInstanceOf($toResolve);12 }
Here we have to be a bit crafty. Anonymous classes are defined and instantiated at the same time, so we can't pass it in as an anonymous class in the test. Instead, we call the anonymous class and get its name, then set that as the second argument to set()
. Then we can verify that the returned object is an instance of the same class.
Running this throws an error:
1$ vendor/bin/phpspec run2Matthewbdaly/Ernie/Container3 86 - it can resolve dependencies4 exception [err:ArgumentCountError("Too few arguments to function class@anonymous::__construct(), 0 passed and exactly 1 expected")] has been thrown.56 94% 1873 specs818 examples (17 passed, 1 broken)960ms
This is expected. Our test class accepts an instance of DateTime
in the constructor as a mandatory dependency, so instantiating it fails. We need to update the getInstance()
method so that it can handle pulling in any dependencies:
1 private function getInstance(ReflectionClass $item)2 {3 $constructor = $item->getConstructor();4 if (is_null($constructor) || $constructor->getNumberOfRequiredParameters() == 0) {5 return $item->newInstance();6 }7 $params = [];8 foreach ($constructor->getParameters() as $param) {9 if ($type = $param->getType()) {10 $params[] = $this->get($type->getName());11 }12 }13 return $item->newInstanceArgs($params);14 }
Here, we use the Reflection API to get the constructor. If there's no constructor, or it has no required parameters, we just return a new instance of the reflected class as before.
Otherwise, we loop through the required parameters. For each parameter, we get the string representation of the type specified for that parameter, and retrieve an instance of it from the container. Afterwards, we use those parameters to instantiate the object.
Let's run the specs again:
1$ vendor/bin/phpspec run2 100% 1833 specs418 examples (18 passed)551ms
Our container is now complete. We can:
- Resolve simple classes out of the box
- Set arbitrary keys to resolve to particular classes, or the result of callables, so as to enable mapping interfaces to concrete implementations, or resolve classes that require specific non-object parameters, such as PDO
- Resolve complex classes with multiple dependencies
Not too bad for just over 100 lines of PHP...
Final thoughts
As I've said, this is a pretty minimal example of a dependency injection container, and I wouldn't advise using this in production when there are so many existing, mature solutions available. I have no idea how the performance would stack up against existing solutions, or whether there are any issues with it, and quite frankly that's besides the point - this is intended as a learning exercise to understand how dependency injection containers in general work, not as an actual useful piece of code for production. If you want an off-the-shelf container, I'd point you in the direction of league/container
, which has served me well.
You can find the code for this tutorial on GitHub, so if you have any problems, you should take a look there to see where the problem lies. Of course, if you go on to create your own kick-ass container based on this, do let me know!