Syringe allows a Pimple DI container to be created and populated with services defined in configuration files, in the same fashion as Symfony's DI module.
composer require silktide/syringe
Version 3 requires all parameters to be set before we can compile the container, as it resolves all of the parameters up front.
For example, this would work in version 2, but not in 3:
parameters:
foo: "%bar%"
$container = Syringe::build([
"paths" => [__DIR__]
"files" => ["file.yml"]
]);
$container["bar"] = "chicken";
This would work in version 3:
parameters:
foo: "%bar%"
Syringe::build([
"paths" => [__DIR__]
"files" => ["file.yml"],
"parameters" => [
"bar" => "chicken"
]
]);
- We can now cache the built container, saving lots of time on startup
- Tokens can now be escaped by repeating them (50% could be written as "50%%" as a parameter)
- Environment variables can be referenced using a $ token
- There's unit tests :D
- Multibyte support. I haven't done thorough testing, but we now use multibyte safe functions throughout
- Multiple files can share the same namespace like so:
{"my-namespace":["service1.json", "service2.json"]}
Syringe 2.0 is pretty much an 100% rewrite, the functionality should remain more or less the same but the code behind it is vastly vastly different. As such, there are quite a few BC's as I feel it's better to BC once and hard rather than repeatedly
- Now requires PHP 7.1
- Aliases are now denoted through
::
rather than.
. This makes verifying whether something is aliased so much more clean - TagCollection has been reworked to implement an iterator. This means that when we inject a tag in like so: '#collection', it will now return an iterable object instead of an array. This means that we will only build services if and when they are needed. We can still get information about the serviceNames on a TagCollection using
->getServiceNames
- The container is now originally updated using
Syringe::build([])
. Instead of chaining several slightly non-intuitive internal classes as the end user, we now provide a static method that takes an array of configuration options - Containers are now always generated as part of Syringe rather than exposing populating an existing container.
- Files that inherit from each other will now throw exceptions if they overwrite each others services. A new parameter
override
has been added to services. If you are adding a service into the container and are well aware of the fact that it will overwrite an existing service you can set theoverride
flag and it will not throw an error. - Private services have been removed, in practice they added nothing useful but complicated affairs.
- Environment variables are no longer injected through prefixing parameters with
SYRINGE__FOO
as this was a bit clunky and the wrong way around to do it. A new token of$
means we can inject environment variables as parameters like so$foo$
- IniLoader has been removed, the format doesn't suit DI particularly nicely.
- LoaderInterface updated, now requires typehints
- We now support escaping of special tokens (environment, parameter, constant) by character repeating. e.g. a parameter value of 50% would be written as '50%%')
- As we've added a token for environment variables, any uses of $ in parameters will need to be escaped (like so: "I paid $$50 for this shirt")
The simplest method to create and set up a new Container is to use the Silktide\Syringe\Syringe
class. It requires the path to the application directory and a list of filepaths that are relative to that directory
use Silktide\Syringe\Syringe;
$container = \Silktide\Syringe\Syringe::build([
"files" => ["config/syringe.yml"]
]);
The code is hopefully relatively self explanatory with this version, but a basic rundown of the flow of the library for anyone trying to do future maintenance on it.
Syringe::build
is the entrypoint in the library which deals with the configuration aspect.
MasterConfigBuilder
takes the list of files and paths that we'll want to load from and recursively parses the files to build up a in-order list of FileConfig
's, which represent each config file we've been asked to load either through the initial config, imports or inherits.
These are then merged into a MasterConfig.php, which handles merging any services together based on their weight, a property decided based on whether the key was loaded from an namespaced File.
The MasterConfig
is then passed into the CompiledConfigBuilder
which handles aliasing, the building of tags and the merging of abstract configurations.
Finally the CompiledConfig
is passed into a ContainerBuilder
which handles populating the Pimple\\Container
When used in production, you should pass a PSR-16 cache interface (preferably as a cache parameter), like so:
use Silktide\Syringe\Syringe;
$container = \Silktide\Syringe\Syringe::build([
"cache" => new FileCache(sys_get_temp_dir())
]);
The most computationally expensive part of Syringe (certainly when using many syringe based libraries) is: 1. the namespacing of the different parameters and 2. the validating of the classes/methods inside the configuration files
By passing in the cache
parameter we cache the generated CompiledConfig and use that instead. This leads to much, much faster code (takes about 7% of the time)
By default, Syringe allows config files to be in JSON, YAML or PHP format. Each file can define parameters, services and tags to inject into the container, and these entities can be referenced in other areas of configuration.
A Parameter is a named, static value, that can be accessed directly from the Container, or injected into other parameters or services.
For a config file to define a parameter, it uses the parameters
key and then states the parameters name and value.
parameters:
myParam: "value"
Once defined, a parameter can be referenced inside a string value by surrounding its name with the %
symbol and the parameters value will the be inserted when the the string value is resolved. This can be done in service arguments or in other parameters, like so:
parameters:
firstName: "Joe"
lastName: "Bloggs"
fullName: "%firstName% %lastName%"
array: ["foo", "bar"]
object: {"foo":"salad", "bar":"fish"}
Parameters can have any scalar or array value.
Quite often, a value set in a PHP constant is required to be injected. Hard coding these value directly into DI config is brittle and requires maintenance to keep in sync, which should be avoided where possible.
Syringe solves this problem by allowing PHP constants to be referenced directly in config, by surrounding the constant name with ^
characters:
parameters:
maxIntValue: "^PHP_INT_MAX^"
custom: "^MY_CUSTOM_CONSTANT^"
classConstant: "^MyModule\\MyService::CLASS_CONSTANT^"
Where class constants are used, you are required to provide the fully qualified class name. As this has to be enclosed inside a string, all forward slashes must be escaped, as in the example.
Services are instances of a class that can have other services, parameters or values injected into them. A config file defines services inside the services
key and gives each entry a class
key, containing the fully qualified class name to instantiate.
For classes which have constructor arguments, these can be specified by setting the arguments
key to a list of values, parameters or other services, as required by the constructor
services:
myService:
class: MyModule\MyService
arguments:
- "first constructor argument"
- 12345
- false
- {"foo":"salad", "bar":"fish"}
Services can have parameters or other services injected into them as method arguments, by referencing a service name prefixed with the @
character. This is done in one of two ways:
Injection can be done when a service is instantiated, by setting references in arguments
key of a service definition. This is typically done for dependencies which are required.
services:
injectable:
class: MyModule\MyDependency
myService:
class: MyModule\MyService
arguments:
- "@injectable"
- "%myParam%"
Services can also be injected by calling a method after the service has been instantiated, passing the dependant service in as an argument. This form is useful for optional dependencies.
services:
injectable:
class: MyModule\MyDependency
myService:
class: MyModule\MyService
calls:
-
method: "setInjectable"
arguments:
- "@injectable"
The calls
key can be used to run any method on a service, not necessarily one to inject a dependency. They are executed in the order they are defined.
services:
myService:
class: MyModule\MyService
calls:
- method: "warmCache"
- method: "setTimeout"
arguments: ["%myTimeout%"]
- method: "setLogger"
arguments: ["@myLogger"]
In some cases, you may want to inject all the services of a given type as a method argument. This can be done manually, by building a list of service references in config, but maintaining such a list is cumbersome and time consuming.
The solution is tags; allowing you to tag a service as being part of a collection and then to inject the whole collection of services in one reference.
A tag is referenced by prefixing its name with the #
character.
services:
logHandler1:
...
tags:
- "logHandlers"
logHandler2:
...
tags:
- "logHandlers"
loggerService:
...
arguments:
- "#logHandlers"
When the tag is resolved, the collection is passed through as TagCollection, which can be used as an iterator. This should be typehinted against iterator, not TagCollection unless you're certain you know what you're doing.
If you have a number of services to be available that use the same class or interface, it can be advantageous to abstract the creation of these services into a factory class, to aid maintenance and reusability. Syringe provides two methods of using factories in this way; via a call to a static method on the factory class, or by calling a method on a separate factory service.
services:
newService1:
class: MyModule\MyService
factoryClass: MyModule\MyServiceFactory
factoryMethod: "createdWithStatic"
newService2:
class: MyModule\MyService
factoryService: "@myServiceFactory"
factoryMethod: "createdWithService"
myServiceFactory:
class: MyModule\MyServiceFactory
If the factory methods require arguments, you can pass them through using the arguments
key, in the same way you would for a normal service or a method call.
Syringe allows you to alias a service name to point to another definition, using the aliasOf
key.
This is useful if you deal with other modules and need to use your own version of a service instead of the module's default one.
# [foo.yml]
services:
default:
class: MyModule\DefaultService
...
# [bar.yml]
services:
default:
aliasOf: "@custom"
custom:
class: MyModule\MyService
...
Services can often have definitions that are very similar or contain portions that will always be the same.
As a method to reduce duplicated config, a service's definition can "extend" a base definition. This has the effect of merging the two definitions together. Any key conflicts take the service's value rather than the one from the base, however the list of calls is merged rather than overwritten. There is no restriction on what keys you can define in the base definition.
Base definitions have to be marked as abstract
and cannot be used directly as a service. These abstract definitions can extend other definitions in the same way, similar to how inheritence works in OOP.
services:
loggable:
abstract: true
calls:
- method: "setLogger"
- arguments: "@logger"
myService:
class: MyModule\MyService
extends: "@loggable" # this will import the "setLogger" call into this service definition
factoriedService:
abstract: true
extends: "@loggable"
factoryClass: MyModule\MyServiceFactory
factoryMethod: "create"
myFactoriedService:
class: MyModule\MyService
extends: "@factoriedService" # imports both the factory config and the "setLogger" call
Dependency injection can lead to excessive generation of objects that can have expensive initialisation logic but aren't actually used. As such, syringe offers native support for ProxyManager
services:
runner:
class: MyRunner
arguments:
- "@expensiveService"
expensiveService:
class: MyExpensiveClass
lazy: true
This will inject a proxy object for expensiveService that is indistinguishable to MyExpensiveClass to the code, but prevents it from being loaded un-necessarily
If a service has a __destruct
, ProxyManager will create the service just to ensure that the __destruct logic is
triggered as expected. This feels counter intuitive but it is the right default behaviour.
However, if you're just performing cleanup from your object, it isn't really desired to create an object just to have to perform the cleanup from doing so.
As such, we provide the lazySkipDestruct
property to allow us to overcome this behaviour
services:
runner:
class: MyRunner
arguments:
- "@expensiveService"
expensiveService:
class: MyExpensiveClass
lazy: true
lazySkipDestruct: true
When your object graph becomes large enough, it is often useful to split your configuration into separate files; keeping related parameters and services together. This can be done by using the imports
key:
imports:
- "loggers.yml"
- "users.yml"
- "report/orders.yml"
- "report/products.yml"
services:
...
If any imported files contain duplicated keys, the file that is further down the list wins. As the parent file is always processed last, its services and parameters always take precedence over the imported config.
# [foo.yml]
parameters:
baz: "from foo"
# [bar.yml]
imports:
- "foo.yml"
parameters:
baz: "from bar"
# when bar.yml is loaded into Syringe, the "baz" parameter will have a value of "from bar"
If required, Syringe allows you to set environment variables on the server that will be imported at runtime. This can be used to set different parameter values for local development machines and production servers, for example.
They can be injected similar to parameters using the token of &
like &myvar&
so
When dealing with a large object graph, conflicting service names can become an issue. To avoid this, Syringe allows you to set an namespace for a config file. Within the file, services can be referenced as normal, but files which use different namespaces or no namespace need to prefix the service name with the namespace. This allows you to compartmentalise your DI config for better organisation and to promote modular coding.
For example, the two config files, foo.yml
and bar.yml
can be given namespaces when setting up the config files to create a Container from:
$configFiles = [
"foo_namespace" => "foo.yml",
"bar_namespace" => "bar.yml"
];
foo.yml
could defined a service, fooOne
, which injected another service in the same file, fooTwo
, as normal.
However, if a service in bar.yml
wanted to inject fooTwo
, it would have to use its full service reference @foo_namespace.fooTwo
. Likewise if fooOne
wanted to inject barOne
from bar.yml
it would have to use @bar_namespace.barOne
as the service reference.
There can be times where you need to call setters on a dependent module's services, in order to inject your own dependent service as a replacement for the module's default one.
In order to do this, you need to use the extensions
key. This allows you to specify the service and provide a list of calls to make on it, essentially appending them to the service's own calls
key
# [foo.yml, namespaced with "foo_namespace"]
services:
myService:
class: MyModule\MyService
...
# [bar.yml]
services:
myCustomLogger:
...
extensions:
foo_namespace.myService:
- method: "addLogger"
arguments: "@myCustomLogger"
In order to identify references, the following characters are used:
@
- Services%
- Parameters#
- Tags^
- Constants&
- Environment Variables
Syringe does not enforce naming or style conventions, with one exception. A service's name can be any you like, as long as it does not start with one of the reference characters, but a config namespace is always seperated from a service name with a ::
, e.g. myAlias::serviceName
, as such, this should be avoided in any service/parameter names
parameters:
database.host: "..."
database.username: "..."
database.password: "..."
services:
database.client:
...
In order to use configuration in a particular file, its filepath must be passed to the ContainerBuilder
, which will use the loading system to convert a file into a PHP array. Syringe uses absolute paths when loading files, but this is obviously not ideal when you're passing config filepaths.
In order to get around this, you can add additional paths to the configuration array. For example, for a config file with absolute path of /var/www/app/config/syringe.yml
, you could set a base path of /var/www/app
and use config/syringe.yml
as the relative filepath.
$container = \Silktide\Syringe\Syringe::build([
"paths" => ["/var/www/app"]
"files" => ["config/syringe.yml"]
]);
If you use several base paths, Syringe will look for a config file in each base path in turn, so the order is important.
If you have services that deal with files, it can be very useful to have the base directory of the application as a parameter in DI config, so you can be sure any relative paths you use are correct.
$container = \Silktide\Syringe\Syringe::build([
"appDir" => "my/application/directory", # Application Directory
"appDirKey" => "myAppParameterKey"
]);
If no app directory key is passed, the default parameter name is app.dir
.
Some projects that use Pimple, such a Silex, extend the Container
class to add functionality to their API. Syringe can create custom containers in this way by allowing you to set the container class it instantiates:
$container = \Silktide\Syringe\Syringe::build([
"containerClass" => "Silex\Application::class"
]);
Syringe can support any data format that can be translated into a nested PHP array. Each config file is processed by the loader system, which is comprised of a series of Loader
objects, each handling a single data format, that take a file's contents and decode it into an array of configuration.
By default the ContainerBuilder
loads the PHPLoader, the YamlLoader and the JsonLoader
By default Syringe supports YAML and JSON data formats for the configurations files, but it is possible to use any format that can be translated into a nested PHP array.
The translation is done by a Loader
; a class which takes a filepath, reads the file and decodes the data.
To create a Loader
for your chosen data format, the class needs to implement the LoaderInterface
and state what its name is and what file extensions it supports. For example, a hypothetical XML Loader
would look something like this:
use Silktide\Syringe\Loader\LoaderInterface;
class XmlLoader implements LoaderInterface
{
public function getName()
{
return "XML Loader";
}
public function supports($file)
{
return pathinfo($file, PATHINFO_EXTENSION) == "xml";
}
public function loadFile($file)
{
// load and decode the file, returning the configuration array
}
}
Once created, such a loader can be used by overwriting $config["loaders"] = [new XmlLoader()]
Written by: