kelvinmo / lightcontainer
A lightweight, autowiring, PSR-11 compliant container
Requires
- php: ^7.2 || ^8.0
- psr/container: ^1.1 || ^2.0
Requires (Dev)
- phpstan/phpstan: ^1.3
- phpunit/phpunit: ^8.0 || ^9.0
Provides
README
LightContainer is a simple PSR-11 compliant dependency injection container that supports autowiring.
Table of contents
Requirements
- PHP 7.2 or later
Installation
You can install via Composer.
composer require kelvinmo/lightcontainer
Basic Usage
Consider the following set of classes:
class A {} class B { public function __construct(A $a) {} } class C { public function __construct(B $b) {} }
Normally, if you want to create C
, you will need to do the following:
$c = new C(new B(new A()));
However, with LightContainer you can do this:
$container = new LightContainer\Container(); $c = $container->get(C::class);
Note that absolutely no configuration of the container is required. LightContainer figures out the dependencies based on the type hints provided by the constructor parameters, and then configures itself to create the necessary objects. This is autowiring.
Further details on how autowiring works can be found in the reference.
Configuration
Introduction
You can configure the container through the set
method. This method can be
called with up to two parameters: a string entry identifier, and an optional
value. The main ways you can use this method is used to configure the
container are set out in this section, with further details can be found
in the reference section below.
Instantiation options
You can set instantiation options for a particular class by calling the set
method with the name of the class. This returns a class resolver object,
which then allows you to specify options.
$container->set(D::class)->shared();
Multiple options can be set by chaining up the methods.
$container->set(D::class) ->alias(FooInterface::class, FooInterfaceImpl::class) ->shared();
Certain instantiation options can be set using attributes.
Instantiation options are cleared every time you call the set
method
on the container object. To set additional options on the same
resolver, use the getResolver
method to retrieve the existing resolver.
$container->set(D::class)->shared(); // Correct $container->getResolver(D::class)->alias(FooInterface::class, FooInterfaceImpl::class); // Incorrect - shared() will disappear $container->set(D::class)->alias(FooInterface::class, FooInterfaceImpl::class);
Aliases
Consider the following declarations:
interface FooInterface {} class FooInterfaceImpl implements FooInterface {} class FooInterfaceSubclass extends FooInterfaceImpl {} class D { public function __construct(FooInterface $i) {} }
D
cannot be instantiated by autowiring as FooInterface
is an interface.
We need to specify which concrete class that implements the interface
we want. We can do this by using the alias
method on the resolver.
$container->set(D::class)->alias(FooInterface::class, FooInterfaceImpl::class); $d = $container->get(D::class); // This is equivalent to new D(new FooInterfaceImpl())
You can set multiple aliases with a single call by passing an array.
$container->set(E::class)->alias([ FooInterface::class => FooInterfaceImpl::class, BarInterface::class => BarInterfaceImpl::class ]);
Aliases can refer to other aliases. In the example below, when the container
looks for FooInterface
, it finds FooInterfaceImpl
as an alias, but that
in turn references FooInterfaceSubclass
. In the end, FooInterfaceSubclass
is created. It can also be seen that aliases can be made for classes as well
as interfaces.
$container->set(D::class) ->alias(FooInterface::class, FooInterfaceImpl::class) ->alias(FooInterfaceImpl::class, FooInterfaceSubclass::class); $d = $container->get(D::class); // This is equivalent to new D(new FooInterfaceSubclass())
If you want to define an alias applicable for all classes in the container, consider using a global alias.
Constructor arguments
Consider the following set of classes:
class F {} class G { public function __construct(F $f, string $host, int $port = 80) {} }
G
cannot be instantiated by autowiring as we need to, as a minimum,
specify $host
. We can do this by using the args
method on the resolver.
$container->set(G::class)->args('example.com', 8080); // Multiple calls also work $container->set(G::class) ->args('example.com') ->args(8080); // Optional parameters can be omitted $container->set(G::class)->args('example.com');
The args
method can only be used to specify parameters that:
- do not have a type hint; or
- have a type hint for an internal type (e.g. int, string, bool); or
- for PHP 8, have a type hint that is a union type.
Other parameters (i.e. those with type hints for classes or interfaces)
are ignored when processing the args
method. To manipulate how
these parameters are treated, use aliases or
global aliases.
class H { // PHP 8 is required for this declaration to work public function __construct(F $f, int|string $bar, A $a, $baz) {} } // This sets $bar to 'one' and $baz to 'two' $container->set(H::class)->args('one', 'two');
There may be times where you need to use something from the container as an
argument. This may occur if the parameter in the declaration is not type
hinted (and so you can't use alias
), or if you want to specify a
particular named instance. In these cases
you will need to wrap the entry identifier with Container::ref()
.
class I { /** * @param FooInterface $foo */ public function __construct($foo) {} } // See $foo to the FooInterfaceImpl from the container (which may be // instantiated if required) $container->set(I::class)->args(LightContainer\Container::ref(FooInterfaceImpl::class)); // Set $foo to named instance @foo from the container $container->set(I::class)->args(LightContainer\Container::ref('@foo'));
Setter injection
In addition to dependency injection via the constructor, LightContainer also supports injecting dependencies via setter methods. Consider this declaration:
class J { public function setA(A $a) {} }
To get the container to call setA
to inject A
whenever J
is created,
use the call
method to specify the method to call. Autowiring
works in the same way as for constructor injection.
$container->set(J::class)->call('setA');
The call
method also takes additional arguments, which will be passed
on to the setter method. The rules for specifying additional arguments are the
same as for constructors. In addition,
aliases and global aliases are also
resolved in the same way as for constructor injection.
class K { public function setFoo(FooInterface $f, bool $debug); } $container->set(K::class) ->alias(FooInterface::class, FooInterfaceImpl::class) ->call('setFoo', false);
NOTE. Aliases apply to the constructor and all setter methods. You cannot define aliases that only applies to a particular setter method.
Modifying resolved instances
In addition to setter injection, LightContainer supports other forms of modifying the resolved object before passing it back to the caller.
To do this, create an instance of a class that implements
LightContainer\InstanceModifierInterface
, then pass it to the
container using the modify
method.
class Modifier implements InstanceModifierInterface { public function modify(object $obj, LightContainerInterface $container): object {} } class ModifyMe {} $modifier = new Modifier(); $container->set(ModifyMe::class)->modify($modifier); // The container will call $modifier->modify() before returning the instance $modify_me = $container->get(ModifyMe::class);
Shared instances
There may be times where you want the same instance of a class to be returned by the container no matter how many times it is resolved. This may be the case if the object is meant to be used as a singleton.
To do this, call the shared
method on the resolver.
$container->set(A::class)->shared(); // $a1 and $a2 are the same instance $a1 = $container->get(A::class); $a2 = $container->get(A::class);
You can also switch off this behaviour by calling shared(false)
.
However, this only works if the shared instance has not been created
(i.e. if get
hasn't been called). Otherwise this will throw an
exception.
You can also set this option using the LightContainer\Attributes\Shared
attribute:
#[Shared] class A {} $container->set(A::class); // This is equivalent to: // $container->set(A::class)->shared();
Options for autowired resolvers
Autowired resolvers are created automatically by the container as part of the autowiring process. As autowired resolvers do not have an explicit entry in the container, they inherit the instantiation options for the immediate ancestor class that have an entry in the container.
For example, in the declaration below, the resolver for N
is autowired
as there is no explicit entry in the container for N
. Because L
is
an ancestor class for N
and it has an entry in the container, N
inherits all the instantiation options from L
.
class L {} class M extends L {} class N extends M {} $container->set(L::class)->shared(); // $n1 and $n2 are the same instance because // N inherited 'shared' from L $n1 = $container->get(N::class); $n2 = $container->get(N::class);
To control this behaviour, call propagate(false)
on the resolver.
This will stop instantiation options from being propagated to autowired
resolvers created for its subclasses. For example, in the example
below, N
is an autowired resolver, but does not inherit the options
from L
because propagate
for L
is set to false. Therefore
L
retains the default behaviour of not creating shared instances.
$container->set(L::class)->shared()->propagate(false); // $n1 and $n2 are the different instances because // L does not propagate its options to autowired // subclasses $n1 = $container->get(N::class); $n2 = $container->get(N::class);
You can also set this option using the LightContainer\Attributes\Propagate
attribute:
#[Propagate(false)] class L {}
To set instantiation options for all autowired resolvers, you can use the
special wildcard resolver *
.
// Set 'shared' to true for all autowired resolvers $container->set('*')->shared();
Global aliases
Instead of defining aliases at the class level, you can define a
global alias, which applicable for all classes created by the container.
This can be done by calling the set
method with the name of the class or
interface to be replaced in the first argument, and the name of the concrete
class as the second argument.
Note that the class or interface does not actually need to exist. LightContainer only checks whether the first argument only contains characters that can be used as the fully qualified name of a type (i.e. including the namespace), and if it does, it treats the entry as a global alias. Otherwise it treats the entry as a named instance.
$container->set(FooInterface::class, FooInterfaceImpl::class);
If an alias is also defined at the class level, that definition takes precedence over the global alias.
class FooInterfaceImplGlobal implements FooInterface {} class FooInterfaceImplForD implements FooInterface {} $container->set(FooInterface::class, FooInterfaceImplGlobal::class); $container->set(D::class)->alias(FooInterface::class, FooInterfaceImplForD::class); // D uses FooInterfaceImplForD instead of FooInterfaceImplGlobal $container->get(D::class);
Unlike aliases defined at the class level, you can set instantiation options for global aliases. These are applied to objects instantiated by referring to the identifier of the global alias instead of the concrete class. The following instantiation options are supported:
alias
(additional class aliases)args
(constructor arguments)call
(setters)shared
(shared instances)
Instantiation options specified in the global alias takes precedence over the options defined for the concrete class.
Multiple shared instances
The shared
instantiation option will provide a single
shared instance of a class for the entire container. However, you may want to
have multiple instances of the same class that are shared across the container.
class O { public function __construct(\PDO $db) {} } $container->set('@prod_db', \PDO::class) ->args('mysql:host=example.com;dbname=prod'); $container->set('@dev_db', \PDO::class) ->args('mysql:host=example.com;dbname=dev'); $container->set(O::class)->alias(\PDO::class, '@prod_db');
Named instances are simply a special kind of global aliases,
so they can be used anywhere where an alias is accepted. In particular, they
are injected into constructors and setter methods using alias
, not
args
.
// Correct $container->set(O::class)->alias(\PDO::class, '@prod_db'); // Incorrect $container->set(O::class)->args('@prod_db');
Furthermore, because named instances are simply a special kind of global
aliases, you can set instantiation options for them (other than shared
).
Custom instantiation
Instead of using LightContainer's default instantiation function
with autowiring, you can specify a custom factory function to create
objects of a particular class. This is done by calling set
method with a callable pointing to the factory function as the second
argument.
The container is passed on as the first parameter to the factory function.
$container->set(CustomClass::class, function ($container) { $a = new CustomClass(); // Custom code here return $a; });
Instantiation options are not available to custom factory functions. Named instances are also not available, as sharing is not supported for factory functions. Calls to named instances will simply result in the factory function being called, which may result in a different instance of the object being returned.
Storing arbitrary values
You can store arbitrary values in the container by calling the set
method with a non-string, non-callable value as the second argument.
$container->set('@port', 8080); $container->set('@config', ['foo' => 'bar']); // Returns 8080 $container->get('@port');
It is recommended that you use an identifier that cannot be confused with a
class name (e.g. by adding an invalid character for a class name, like @
in the example above).
WARNING. If you want to store null
, a string or a callable, you will need
to wrap it with using Container::value()
. Otherwise LightContainer will
treat it as a global alias or a
custom instantiation function.
// Correct $container->set('@host', LightContainer\Container::value('example.com')); // Incorrect $container->set('@host', 'example.com');
Configuring services
In certain situations, concrete classes are used to implement services, which are defined using interfaces. Containers are then used to link the services to their implementations.
Services are identified by either:
- using the
LightContainer\Attributes\Service
attribute; or - extending
LightContainer\ServiceInterface
.
// PHP 8 #[Service] interface FooService {} #[Service] interface BarService {} // PHP 7 interface FooService extends ServiceInterface {} interface BarService extends ServiceInterface {} class Baz implements FooService, BarService {}
Instead of calling the set
method for each service separately, you can
call the populate
method on the concrete class. This method uses reflection
to determine which services the class implements, and then creates
container entries accordingly.
// This: $container->populate(Baz::class); // is equivalent to: $container->set(FooService::class, Baz::class); $container->set(BarService::class, Baz::class);
Loading configurations
Instead of configuring the container programmatically, you can also load a
pre-defined configuration directly into the container using the load
method.
This method takes the container configuration as a plain PHP array, which you
can populate using whichever method you want (e.g. loading from a JSON or
YAML file).
try { $config = [ 'FooInterface' => 'FooInterfaceImpl', '@host' => [ '_value' => 'example.com' ] ]; $container->load($config); } catch (LightContainer\Loader\LoaderException $e) { }
See the configuration format documentation for details on how the configuration array should be structured. Note that only a subset of LightContainer's features are supported by this format.
Reference
Resolvers
At its core, LightContainer stores a mapping between an entry identifier
and a resolver. A resolver is an object that implements
LightContainer\Resolvers\ResolverInterface
, which the container uses
to resolve to an object or some other value whenever the get
method
is called.
The main kind of resolver is a class resolver, which resolves to an object by instantiating it from the specified class. Other key kinds of resolvers include a reference resolver, which looks up another entry in the container, and a value resolver, which simply returns a specified value.
The set
method takes the string entry identifier and the specified value,
creates and registers a resolver, and then returns it to the user. The
kind of resolver the method creates depends on the format of
the entry identifier, and the type of value that is specified. These are
set out in the table below.
The class resolver returned for *
is a special kind of class resolver. It
cannot be called to resolve to an actual object.
Autowiring
Autowiring works by using PHP reflection to examine the parameters of the constructor (or setter method). If any parameter has a type hint, and the hinted type is not a built-in literal type, it looks up the container to see if there is an existing entry with the type as the entry identifier. If an entry does not exist, but the specified type is a class and the class exists, the container then creates a class resolver (the autowired resolver) for that type automatically. The instantiation options for this autowired resolver are set based on the propagation rules described above.
Note that when injecting parameters into the constructor or setter method,
the nullable market (?
) and default value declarations are initially ignored.
The container will try to create and inject an instance of a particular
type even it is declared nullable or contain a default value. Only if the
container cannot create an instance will a null or default value be used.
interface ImplementedInterface {} class InterfaceImplementation implements ImplementedInterface {} interface UnimplementedInterface {} class P1 { function __construct(?ImplementedInterface $i) {} } class P2 { function __construct(ImplementedInterface $i = null) {} } class Q1 { function __construct(?UnimplementedInterface $i) {} } class Q2 { function __construct(UnimplementedInterface $i = null) {} } $container->set(ImplementedInterface::class, InterfaceImplementation::class); // $p1->i and $p2->i will both contain a InterfaceImplementation, // as an alias is registered for ImplementedInterface and the aliased class // exists $p1 = $container->get(P1::class); $p2 = $container->get(P2::class); // $q1->i and $q2->i will both be null, as there is no alias registered for // UnimplementedInterface $q1 = $container->get(Q1::class); $q2 = $container->get(Q2::class);
In order to pass a null as an argument to a nullable type, you have to use
alias
to explicitly declare an alias to null.
$container->set(P1::class)->alias(ImplementedInterface::class, null); $container->set(P2::class)->alias(ImplementedInterface::class, null); // $p1->i and $p2->i are now both null $p1 = $container->get(P1::class); $p2 = $container->get(P2::class);
Instantiation options reference
The following instantiation options are available for class resolvers. These
options (apart from propagate
) are also available for
global aliases.
Licence
BSD 3 clause