Pimple for the PHP 8 era:
- IDE support, static type-checking, auto-completions.
- Full container bootstrapping validation at startup.
- Performance on par with that of Pimple.
- Verbosity similar to Pimple, but more declarative.
- Mutable Context, immutable Containers.
This container was designed specifically for PHP 8.x to leverage fn
function expressions with attributes for configuration.
Compared with some more complex container libraries, bootstrapping may be more verbose, but is also more explicit - every component has a defined factory function, which makes it possible to validate all dependencies up front, without actually loading any classes. The use of function expressions enable an IDE or static analysis tool to verify and type-check all constructor calls.
This container has two primary APIs:
Context
represents a logical dependency injection context - this is where you register your component/service definitions.Container
represents an actual container instance - this is where your component/service instances exist.
First off, create a Context
and bootstrap it:
$context = new Context();
$context->register(
Cache::class,
fn (string $CACHE_PATH) => new FileCache($path)
);
$context->set("CACHE_PATH", "/tmp/cache");
$context->register(
"db.write-master",
fn () => new Database()
);
$context->register(
UserRepository::class,
fn (#[id("db.write-master")] Database $db, Cache $cache) => new UserRepository($db, $cache)
);
Note how the string $CACHE_PATH
argument is resolved using the parameter name CACHE_PATH
- this fallback is available for built-in value-types (such as string
, int
, float
, bool
and array
) because these are always registered under a logical name. You can load configuration values from JSON or INI files, or from the system environment, using Config
providers - this will be covered below.
Next, note the use of the #[id("db.write-master")]
attribute applied to the Database $db
argument for the UserRepository
factory function - this tells the container to resolve the dependency using the component named db.write-master
. This pattern is useful when you have multiple instances of the same class.
By convention:
- Singletons should be registered using
::class
expressions. - Named instances should be registered using
dotted.lower.case
names. - Configuration values should be registered using
ALL_CAPS
names.
Following these conventions prevents component name collisions.
If you're wondering how or why, here's a longer explanation:Registering configuration values such as
CACHE_PATH
under an ALL-CAPS name ensures you can use them in function expressions, such asfn (string $CACHE_PATH)
, without needing anid
attribute.Registering named instances under dotted names such as
db.write-master
conversely ensures they cannot accidentally be referenced in function expressions without anid
attribute - because you can't use characters like dots or hyphens in argument names.As for singletons, such as
ClassName::class
orInterfaceName::class
, these are always referenced with a type-hint in function expressions, such asfn (Cache $cache)
- since these type-hints are not built-in value-types such asint
,string
,float
, etc.
Once your Context
is ready, create a Container
, and you can look up a component instance:
$container = $context->createContainer();
$cache = $container->get(UserRepository::class);
The dependencies of the UserRepository
factory-function will get resolved and injected.
Note that validation of the Context
takes place when you first call createContainer
- any
unsatisfied dependencies will generate an UnsatisfiedDependencyException
, which enables you to
catch and correct mistakes as early as possible.
You can achieve reusable bootstrapping by wrapping registrations in a Provider
implementation:
class CacheProvider implements Provider
{
public function register(Context $context)
{
$context->register(
Cache::class,
fn (string $CACHE_PATH) => new FileCache($path)
);
$context->set("CACHE_PATH", "/tmp/cache");
}
}
Then use the add
method to apply the provider to a Context
:
$context->add(new CacheProvider());
The built-in Config
provider allows you to load configuration from standard JSON or INI files, and/or import your system environment variables. You can use configuration providers to decouple yourself from configuration sources, e.g. loading different configuration files in production or staging, or injecting configuration values directly in tests.
The advantage of keeping configuration values in the container (as opposed to using some sort of configuration facility) is you get consistent dependency validation up front - the values in your configuration are just dependencies, same as any other. You can connect these dependencies to the components where they're needed, the exact same way you connect every other component in your application.
As an example, here's a provider that requires a CACHE_PATH
configuration value:
class CacheProvider implements Provider
{
public function register(Context $context)
{
$context->register(
Cache::class,
fn (string $CACHE_PATH) => new FileCache($CACHE_PATH)
);
}
}
Note that CACHE_PATH
is not defined by the provider itself - we can load this value from a config.json
file like this one:
{
"CACHE": {
"PATH": "/tmp/my-app/cache"
}
}
And then bootstrap it like this:
$context->add(Config::fromJSON("config.json"));
Similarly, we could load this value from a config.ini
file like this one:
[cache]
path = /tmp/my-app/cache
And then bootstrap it like this:
$context->add(Config::fromINI("config.ini"));
If you prefer using system environment variables, that's possible too, e.g. from a bash script:
CACHE_PATH=/tmp
And then bootstrap the system environment:
$context->add(Config::fromEnv());