As I'm working currently a lot with the Hexagonal Architecture
and so want to keep my Business Logic framework independent. It did change
a lot how I'm structuring reusable libraries.
Before a reusable library mostly was a Symfony Bundle
and had a namespace
like:
namespace App\Bundle\MyLibraryBundle;
With adopting Hexagonal Architecture
for my needs I did restructure my
bundle / library structure. I mostly see the bundle as one bounded context
and so the src
is split in my case into the following
Hexagonal Architecture
Layers:
- src
- Application
- Domain
- Infrastructure
- UserInterface
I will in this blog post not go into the deep about Hexgonal Architecture
and framework independent development that is a Bigger Topic
on its own which I'm working on.
The integration of the library into the Symfony Ecosystem is done by the Bundle
class.
Which is required to be registered in the Project.
In my case now the Bundle class lives under Infrastructure\Symfony
. So as before the Bundle
class was something
like:
use App\Bundle\MyLibraryBundle;
To disconnect from that very framework specific structure my main namespace is now App\MyLibrary
and the integration to Symfony lives in the Infrastructure
Layer now under Symfony\HttpKernel
:
use App\MyLibrary\Infrastructure\Symfony\HttpKernel\MyLibraryBundle;
Actually the integration of a library into Symfony Ecosystem. Is done by 3 different components/classes:
The Bundle class is the main class integrating a library via its own
Bundle class into the Symfony Kernel. The interface is so provided
by symfony/http-kernel
package. Its main responsibility is to provide
Extension class, but also can configure something on boot
of the Symfony Kernel or add CompilerPasses
in its build
method.
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MyLibraryBundle extends Bundle
{
}
The Extension class is configured and create by the Bundle, and is using
the symfony/dependency-injection
package.
Its responsibility is to load configure services and parameters in the
symfony container based on configuration on its provided Configuration class.
use Symfony\Component\DependencyInjection\Extension\Extension;
class MyLibraryExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
}
}
The Configuration class is configured by the Extension and is integrating
the library is using symfony/config
. Its responsibility is to define the
configuration tree which can be used by the Extension class to configure
services.
use Symfony\Component\DependencyInjection\Extension\Extension;
class Configuratioan extends Configuration
{
public function load(array $configs, ContainerBuilder $container): void
{
}
}
If we look "beyond the tellerrand" there the integration of your library into other frameworks is done via different but a little similar classes.
In the Spiral framework it is done via a Bootloader Class
.
In the Laravel framework it is done via a Service Provider Class
.
In the Laminas framework it is done via a Module Class
and additional module.config.php
for that module.
So now the questions is what I asked myself is can I combine all 3 classes of symfony into a single instance, so the integration of my library into Symfony ecosystem lives in that one class.
First we need to discover which Interfaces
we need for the 3 components. As a base for the class
I used the Bundle
class and added to implement the ExtensionInterface
and the ConfigurationInterface
:
<?php
namespace App\MyLibrary\Infrastructure\Symfony\HttpKernel;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MyLibraryBundle extends Bundle implements ExtensionInterface, ConfigurationInterface
{
}
To define our root
directory of our bundle. This mostly required when we have beside the
src
directory also translations
, public
and other directory from the symfony directory
structure we define the path
of the bundle:
class MyLibraryBundle extends Bundle implements ExtensionInterface, ConfigurationInterface
{
public function getPath(): string
{
return \dirname(__DIR__, 4);
}
}
In the next step we are providing the Extension
by default symfony uses some magic
to detect where the extension class exists and create it. The logic can be found
here in the Bundle::getContainerExtension method.
We are implementing this method the following way to return $this
instead
of creating an extra extension instance there:
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
class MyLibraryBundle extends Bundle implements ExtensionInterface, ConfigurationInterface
{
public function getContainerExtension(): ?ExtensionInterface
{
return $this;
}
}
The ExtensionInterface
forces us to implement the following required methods:
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
class MyLibraryBundle extends Bundle implements ExtensionInterface, ConfigurationInterface
{
public const ALIAS = 'my_library';
public function getAlias(): string
{
return self::ALIAS;
}
public function getXsdValidationBasePath()
{
return false;
}
/**
* @param array<string, mixed> $config
*/
public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface
{
// ...
}
/**
* @param array<string, mixed> $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
// load our services
}
}
Actually there is an additional method the ExtensionInterface::getNamespace
which provides an XML namespace. This is conflicting with Bundle::getNamespace
.
As it is uncommon today using xml
to configure a bundle I did ignore this conflict,
as it did work without any problems for me.
The last required instance is the Configuration which we need provide by the ExtensionInterface
by Default this was also auto discovered by some magic in the Extension
.
We are implementing this method the following way by returning again $this
instead of providing an extra instance of configuration class:
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class MyLibraryBundle extends Bundle implements ExtensionInterface, ConfigurationInterface
{
/**
* @param array<string, mixed> $config
*/
public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface
{
return $this;
}
}
The ConfigurationInterface
forces us to implement a single method to define the configuration tree:
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class MyLibraryBundle extends Bundle implements ExtensionInterface, ConfigurationInterface
{
public const ALIAS = 'my_library';
public function getConfigTreeBuilder(): TreeBuilder
{
return new TreeBuilder(self::ALIAS);
}
}
In some cases our integration need to configure other bundles in Symfony this is done
via the PrependExtensionInterface
on the Extension Class. To make sure this also works we can also add that Interface
also to our instance:
<?php
namespace App\MyLibrary\Infrastructure\Symfony\HttpKernel;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
class MyLibraryBundle extends Bundle implements ExtensionInterface, ConfigurationInterface, PrependExtensionInterface
{
public function prepend(ContainerBuilder $container): void
{
// define other bundle configurations
}
}
The whole result is then looking like the following MyLibraryBundle:
<?php
namespace App\MyLibrary\Infrastructure\Symfony\HttpKernel;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\ExtensionInterface;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MyLibraryBundle extends Bundle implements ExtensionInterface, ConfigurationInterface, PrependExtensionInterface
{
public const ALIAS = 'my_library';
public function getPath(): string
{
return \dirname(__DIR__, 4);
}
public function getAlias(): string
{
return self::ALIAS;
}
public function getXsdValidationBasePath()
{
return false;
}
/**
* @param array<string, mixed> $config
*/
public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface
{
return $this;
}
public function prepend(ContainerBuilder $container): void
{
// define other bundle configurations
}
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder(self::ALIAS);
// define our configuration tree
return $treeBuilder;
}
/**
* @param array<string, mixed> $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
// define our services and parameters based on the configuration
}
}
As we see now have at the bottom first the prepend
method to configure other bundles.
Then we have the getConfigTreeBuilder
method to define our configuration tree. And
at the bottom the load
method to define our services and parameters.
In the Symfony project our bundle just need to be registered in the config/bundles.php
return [
// ...
App\MyLibrary\Infrastructure\Symfony\HttpKernel\MyLibraryBundle::class => ['all' => true],
];
The above solution make it from my point provides a better Developer Experience if you
are working on creating the Configuration
Tree and defining its effects on the defined services
and parameters. As in that case you not longer need to jump between the Configuration
and Extension
classes.
It also has some limitations as we can not longer define different default values
based for example on kernel.debug
parameter like it is done in the FrameworkExtension.
From symfony framework point of view it totally make sense that a Bundle integration is split into the 3 classes, as all 3 classes depends on 3 different symfony components and are following the single responsibility principle:
symfony/http-kernel
symfony/dependency-injection
symfony/config
Still I wish the default would be a more common integration class from Developer Experience point of view.
For that case a conflict between the Bundle::getNamespace
and Extension::getNamespace
would needed to be fixed,
maybe by renaming the Extension method to Extension::getXMLNamespace
.
I hope with this article I did atleast make clearer how the Bundle
, Extension
and Configuration
class work
together are how they are created. Also shown how flexible Symfony itself is and that you are not forced to
follow the Directory structure Symfony uses by default for a Bundle
.
If you as example want to integrate your library into other frameworks you could then create something like:
App\MyLibrary\Infrastructure\Spiral\Boot\MyLibraryBootloader
App\MyLibrary\Infrastructure\Laravel\Support\MyLibraryServiceProvider
App\MyLibrary\Infrastructure\Laminas\ModuleManager\MyLibraryModule
In that case you maybe want to create a subtree split of your repository to provide the Integration as own
package my/library
, my/library-symfony
, my/library-laravel
and my/library-symfony
. How such a library
could look like is the Topic of my Hexagonal Architecture Study
article which is still in process.
Tell me what you think about merging Bundle, Extension and Configuration together by attend the discussion about this post on Twitter.
After discussion in the following issue symfony/symfony#45607, I was a little bit sad that it was something Symfony don't want to support. But with the upcoming Symfony 6.1 release, they seems to rethink about the DI/Bundle issue and are now providing the new AbstractBundle.
Read more about it on the Symfony Blog: https://symfony.com/blog/new-in-symfony-6-1-simpler-bundle-extension-and-configuration.