Master universal module development!
So you have developed a package, and you want to make it available to many frameworks out there. Welcome!
This might be obvious, but let's say it anyway. Universal modules MUST be Composer packages.
The composer.json
file of your package MUST require container-interop/service-provider
:
{
"require": {
"container-interop/service-provider": "^1.0"
}
}
The first question you will have to answer is: do I want the universal module to be part of my main package or is it an additional package?
We are ok with both solutions. If you provide the universal module as an extra package next to your main package, please name it with a -universal-module
suffix.
For instance, if your module is named foo/bar
, the universal module should be foo/bar-universal-module
.
A universal module contains at least one service provider.
This service provider MUST implement the Interop\Container\ServiceProvider
interface.
The service provider MUST NOT have a constructor with compulsory parameters. It is ok if the service provider has a constructor with some optional parameters.
Constructor parameters are ok if they are altering the behaviour of a service provider (for instance by modifying the default name of the provided services).
Configuration MUST be fetched from the container. Each framework stores configuration in a different place.
app/config/config.yml
.config/app.php
.As far as you are concerned, this is not an issue. As long as you are using a compatible framework, configuration can be accessed from the container.
So if your service provider needs a LOGFILE
configuration parameter, you can simply use $container->get('LOGFILE')
.
Of course, you will need to let the developer know you are expecting such a parameter to exist. More on this in the documentation chapter below.
class DbConnectionProvider implements ServiceProvider {
private $dbHost;
private $dbUser;
private $dbPassword;
/**
* Such a constructor is NOT ok. You are forcing the developer to pass parameters in the constructor
* arguments, hence defeating the purpose of the dependency-injection container.
*/
public function __construct(string $dbHost, string $dbUser, string $dbPassword)
{
$this->dbHost = $dbHost;
$this->dbUser = $dbUser;
$this->dbPassword = $dbPassword;
}
public function getServices()
{
return [
DbConnection::class => [ self::class, 'createConnection' ]
];
}
public static function createConnection()
{
return new DbConnection($this->dbHost, $this->dbUser, $this->dbPassword);
}
}
class DbConnectionProvider implements ServiceProvider {
public function getServices()
{
return [
DbConnection::class => [ self::class, 'createConnection' ]
];
}
public static function createConnection(ContainerInterface $container)
{
// This is ok: configuration is fetched from the container.
return new DbConnection(
$container->get('my.package.dbhost'),
$container->get('my.package.dbuser'),
$container->get('my.package.dbpassword')
);
}
}
Universal modules MUST provide a Puli binding for the service providers they are providing.
This way, users of your universal module will just have to require the module in Composer and automatically, your service provider will be detected and your service provider entries will be available in the container.
Not used to Puli? Here is a crash course:
In composer.json
, add the puli/cli
and puli/composer-plugin
in require-dev
:
composer require --dev puli/cli
composer require --dev puli/composer-plugin
Then, simply use Puli's bind
command:
vendor/bin/puli bind --class Acme\\Foo\\MyServiceProvider container-interop/service-provider
That's it! Do not forget to commit the new puli.json
file in your package repository!
If your service provider is creating a service (a class that is most of the time meant to be instantiated only once), the name of your service should be the fully qualified name of the class.
For instance, a database connection or a logger that logs to a file are services. Most of the time, you only need one database connection, or one log file. Of course, you can create later more instances of the same class using your container, but the service provider will help you get started with a sensible default of one instance.
class MyServiceProvider implements ServiceProvider {
public function getServices()
{
return [
// The 'myModuleService' is not matching the fully qualified class name of 'MyService'.
'myModuleService' => function() {
return new MyService();
}
];
}
}
class MyServiceProvider implements ServiceProvider {
public function getServices()
{
return [
MyService::class => function() {
return new MyService();
}
];
}
}
If your service is providing a default implementation for a well-known interface, you can also create an alias on the fully qualified name of the interface.
class MyLoggerProvider implements ServiceProvider {
public function getServices()
{
return [
// Let's assume FileLogger implements PSR-3's LoggerInterface
// The FileLogger should NOT be connected to the LoggerInterface directly.
// The LoggerInterface can be overloaded by another service provider and your FileLogger entry will be lost forever.
LoggerInterface::class => function(ContainerInterface $container) {
return new FileLogger($this->container->get('LOGFILE'));
}
];
}
}
class MyLoggerProvider implements ServiceProvider {
public function getServices()
{
return [
// Let's assume FileLogger implements PSR-3's LoggerInterface
FileLogger::class => function(ContainerInterface $container) {
return new FileLogger($this->container->get('LOGFILE'));
},
// Then our logger can claim the default instance by creating an alias on the interface name
LoggerInterface::class => new Alias(FileLogger::class)
];
}
}
Note: other service providers may also claim the same interface. The last service provided registered will "win".
But since the FileLogger is the only one to claim its own class name, the user can later decide to use this specific FileLogger using the FileLogger::class
entry.
Finally, your service provider may create many instances of the same class. This is particularly true if your service provider is providing objects that are not services.
For instance, a service provider could decide many file loggers (one log file for errors, one for debug). Or another service provider might decide to offer menu items to be put in a menu...
So if your package is creating 2 file loggers and none is generic enough to claim the FileLogger::class
entry, then a good name for those logger would be:
vendor_name/package_name:errorLogger
vendor_name/package_name:debugLogger
Ok, this is probably a little confusing, so let's take an example.
If your service provider expects a LOGFILE
parameter, and another service provider expects a LOGFILE
parameter too, there is no way you can feed 2 different LOGFILE
parameters to your 2 service providers.
Instead, your service provider should rely on a parameter named "[vendor_name].[package_name].LOGFILE".
Of course, having to provide a parameter named "my_company.my_package.LOGFILE" if a bit tiresome. So your service provider could add an alias to the more simple "LOGFILE" this way:
class MyLoggerProvider implements ServiceProvider {
public function getServices()
{
return [
FileLogger::class => function(ContainerInterface $container) {
return new FileLogger($this->container->get('my_company.mypackage.LOGFILE'));
},
'my_company.mypackage.LOGFILE' => new Alias('LOGFILE')
];
}
}
So now, the user can simply provide a LOGFILE
parameter in its configuration. But if for some reason, the parameter LOGFILE
is also used by another service provider and the user wants to feed 2 different values to the 2 service providers, the user can instead provide a my_company.mypackage.LOGFILE
. This parameter will override the alias defined in the service provider. Shazam! We have a simple solution, yet flexible in case things get more complicated.
Compiled and cached containers can optimize a lot the instantiation of services. In particular, most of them are capable of completely bypassing the instantiation of the service provider and the call to the getServices
method, provided the factory can be called directly.
This way, optimized containers get a way to offer better performances.
class MyServiceProvider implements ServiceProvider {
public function getServices()
{
return [
// To call the closure, any container must first instantiate the service provider and call the getServices method.
MyService::class => function() {
return new MyService();
}
];
}
}
class MyServiceProvider implements ServiceProvider {
public function getServices()
{
return [
MyService::class => [ self::class, 'createMyService' ]
];
}
public static function createMyService() {
return new MyService();
}
}
Container-interop provides a set of tools to ease the writing of service providers. Those tools are presented as callable classes (these classes provide a __invoke
method).
By using these classes (instead of developing factories yourself), you can help compiled and cache containers to improve the performance.
For instance, if you use the Alias
class to create an alias, a compiled container could recognize the Alias
class and resolve this alias at runtime.
So if your service provider is providing aliases, extensible arrays or configuration parameters, you should use the container-interop/common-factories
package.
composer require container-interop/common-factories
See container-interop/common-factories documentation for more information
TODO: add link when package is created.
It is important to provide a detailed documentation of your service provider to your users.
Your documentation should explain to the user:
Below is a sample markdown documentation of a service provider:
## Provided services
This *service provider* provides the following services:
| Service name | Description |
|-----------------------------|--------------------------------------|
| `Doctrine\DBAL\Connection` | A DBAL connection to your database |
## Extended services
This *service provider* extends the following services.
| Service name | Description |
|-----------------------------|-------------------------------------------------------|
| `twig.extensions` | Registers the twig extension in the extensions list |
## Expected values / services
This *service provider* expects the following configuration / services to be available:
| Name | Compulsory | Description |
|------------------------|------------|-----------------------------------------------------------------------------------------------|
| `dbal.host` | *no* | The database host. Defaults to *localhost* |
| `dbal.user` | *no* | The database user. Defaults to *root* |
| `dbal.dbname` | **yes** | The database name. |
| `Doctrine\DBAL\Driver` | *no* | The DBAL driver to use to create the connection. Defaults to DBAL's PDO_MySQL Driver service |
TODO: create badge, add it. (note: badge can be easily created on shields.io)
TODO
TODO: common names => list of routers => list of cache service (???)