The Naked Bundle
Matthias Noback
Assuming you all have a
working project
https://github.com/matthiasnoback/
high-quality-bundles-project
Generate a bundle
Use app/console generate:bundle
Namespace: Dpc/Bundle/TutorialBundle
Bundle name: DpcTutorialBundle
Configuration: yml
Whole directory structure: yes
The full directory structure of a bundle:
What's wrong?
Too many comments
Routing and a controller
Translations
Twig templates
A useless test
You are not going to use it all,
but it will be committed!
Before we continue, clean up your
bundle
Remove the following files and directories:
Controller
Resources/doc
Resources/public
Resources/translations
Resources/views
Tests
Also remove any superfluous comments!
The official
view on
bundles
First-class citizens
Documentation » The Quick Tour » The Architecture
I think your code is more important than the framework,
which should be considered an implementation detail.
All your code lives in a
bundle
Documentation » The Book » Creating Pages in Symfony2
I don't think that's a good idea.
It contradicts the promise of reuse of "pre-built feature
packages".
Almost everything lives
inside a bundle
Documentation » Glossary
Which is not really true, because many things live inside
libraries (e.g. the Symfony components), which is good.
Best practices
Documentation » Cookbook » Bundles
Controllers
Controllers don't need to extend anything at all.
ContainerAware*should be avoided in all cases.
Tests
What's up with the 95%?
Twig
Why Twig? I though Symfony didn't care about this.
Documentation » The Book » Creating and Using Templates
The old view on bundles is
not sufficient anymore
People are reimplementing things because existing
solutions are too tightly coupled to a framework (or even a
specific version).
Why is it necessary to do all these things again for Symfony,
Laravel, Zend, CodeIgniter, CakePHP, etc.?
Last year I started working
on this
Then it became this
About bundles
A bundle is...
A thin layer of Framework-specific
configuration to make resources from some
library available in a Symfony2 application.
A "Symfony application"
meaning:
A project that depends on the Symfony FrameworkBundle.
Resources are
Routes (Symfony Routing Component)
Services (Symfony DependencyInjection Component)
Templates (Twig)
Form types (Symfony Form Component)
Mapping metadata (Doctrine ORM, MongoDB ODM, etc.)
Translations (Symfony Translation Component)
Commands (Symfony Console Component)
...?
So: a bundle is mainly configuration to make these resources
available, the rest is elsewhere in a library.
I also wrote
The challenge
Make the bundle as clean as possible
Entities
Create an entity
Use app/console doctrine:generate:entity
Specs
The entity shortcut name: DpcTutorialBundle:Post.
Configuration format: annotation
It has a title(string) field.
Run app/console doctrine:schema:createor
update --forceand make sure your entity has a
corresponding table in your database.
Let's say you've modelled the Post
entity very well
You may want to reuse this in other projects.
Yet it's only useful if that project uses Doctrine ORM too!
Why?
Annotations couple the Postclass to Doctrine ORM.
(Since annotations are classes!)
Also: why are my entities inside a
bundle?
They are not only useful inside a Symfony project.
Move the entity to another
namespace
E.g. DpcTutorialModelPost.
Create an XML mapping file
E.g. DpcTutorialModelMappingPost.orm.xml
<doctrine-mappingxmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-ma
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entityname="DpcTutorialModelPost">
<idname="id"type="integer">
<generatorstrategy="AUTO"/>
</id>
<fieldname="title"type="string"/>
</entity>
</doctrine-mapping>
You can copy the basic XML from
/vendor/doctrine/orm/docs/en/reference/xml-
mapping.rst.
In fact
Always use XML mapping, it makes a lot of sense, and you
get auto-completion in your IDE!
Remove all ORM things (annotations) from the Postclass
If you are going to try the following at home:
Update DoctrineBundle
Modify composer.json:
{
"require":{
...
"doctrine/doctrine-bundle":"~1.2@dev"
}
}
Run composer update doctrine/doctrine-bundle
Add a compiler pass to your bundle
It will load the XML mapping files
useDoctrineBundleDoctrineBundleDependencyInjectionCompilerDoctrineOrmMappingsPass;
classDpcTutorialBundle
{
publicfunctionbuild(ContainerBuilder$container)
{
$container->addCompilerPass($this->buildMappingCompilerPass());
}
privatefunctionbuildMappingCompilerPass()
{
returnDoctrineOrmMappingsPass::createXmlMappingDriver(
array(
__DIR__.'/../../Test/Model/Mapping/'
=>'DpcTutorialModel'
)
);
}
}
What have we won?
Clean model classes
They are reusable in non-Symfony projects
They are reusable with different persistence libraries
Documentation » The Cookbook » Doctrine » How to provide model classes for several Doctrine
implementations
Controllers
Create a controller
Use app/console generate:controller
Specs
Name: DpcTutorialBundle:Post
Configuration: annotation
Template: twig
The route contains an idparameter.
Action: showAction
Route: /post/{id}/show
Implement the following logic
Modify the action to retrieve a Postentity from the
database:
publicfunctionshowAction(Post$post)
{
returnarray('post'=>$post);
}
Don't forget to register the route
#inthebundle'srouting.ymlfile:
DpcTutorialBundle_Controllers:
resource:"@DpcTutorialBundle/Controller"
type:"annotation"
By the way
Consider using XML for routing too!
For the same reasons
Does all of this really need
to be inside the bundle?
Move the controller class to the
library
Remove parent Controllerclass
We are going to inject every dependency by hand instead of
relying on the service container.
Create a service for the controller
services:
dpc_tutorial.post_controller:
class:DpcTutorialControllerPostController
Remove @Routeannotations
Instead: define actual routes in the bundle's routing.yml
file.
Use the service id of the controller instead of its class name.
dpc_tutorial.post_controller.show:
path:/post/{id}/show
defaults:
_controller:dpc_tutorial.post_controller:showAction
Remove @Templateannotations
Inject the templatingservice instead and use it to render
the template.
useSymfonyComponentHttpFoundationResponse;
useSymfonyComponentTemplatingEngineInterface;
classPostController
{
publicfunction__construct(EngineInterface$templating)
{
$this->templating=$templating;
}
publicfunctionshowAction(Post$post)
{
returnnewResponse(
$this->templating->render(
'DpcTutorialBundle:Post:show.html.twig',
array('post'=>$post)
)
);
}
}
services:
dpc_tutorial.post_controller:
class:DpcTutorialControllerPostController
arguments:
-@templating
What about the
Templates
Move the template to the library
E.g. from Dpc/Bundle/TutorialBundle/Resources/views/Post/show.html.twigto
Dpc/Tutorial/View/Post/show.html.twig
Change the template reference
$this->templating->render(
'@DpcTutorial/Post/show.html.twig',
array('post'=>$post)
)
Register the new location of the
templates
#inconfig.yml
twig:
...
paths:
"%kernel.root_dir%/../src/Dpc/Tutorial/View":DpcTutorial
Documentation » The Cookbook » Templating » How to use and Register namespaced Twig Paths
Well...
We don't want to ask users to modify their config.yml!
Let's prepend
configuration
useSymfonyComponentDependencyInjectionExtensionPrependExtensionInterface;
classDpcTutorialExtensionextendsConfigurableExtensionimplementsPrependExtensionInter
{
...
publicfunctionprepend(ContainerBuilder$container)
{
$bundles=$container->getParameter('kernel.bundles');
if(!isset($bundles['TwigBundle'])){
return;
}
$container->prependExtensionConfig(
'twig',
array(
'paths'=>array(
"%kernel.root_dir%/../src/Dpc/Tutorial/View"=>'DpcTutorial'
)
)
);
}
}
Documentation » The Cookbook » Bundles » How to simplify configuration of multiple Bundles
One last step!
The action's $postargument relies on something called
.param converters
Those convert the idfrom the route to the actual Post
entity.
This is actually Symfony framework-specific behavior
Rewrite the controller to make use
of a repository
useDoctrineCommonPersistenceObjectRepository;
classPostController
{
publicfunction__construct(...,ObjectRepository$postRepository)
{
...
$this->postRepository=$postRepository;
}
publicfunctionshowAction($id)
{
$post=$this->postRepository->find($id);
if(!($postinstanceofPost)){
thrownewNotFoundHttpException();
}
...
}
}
services:
dpc_tutorial.post_controller:
class:DpcTutorialControllerPostController
arguments:
-@templating
-@dpc_tutorial.post_repository
dpc_tutorial.post_repository:
class:DoctrineCommonPersistenceObjectRepository
factory_service:doctrine
factory_method:getRepository
arguments:
-DpcTutorialModelPost
What do we have now?
Reusable templates
Reusable controllers
They work with Silex too!
Who would have though that was possible?
Console commands
Create a console command
Use app/console generate:console-command
Make it insert a new post in the database.
It takes one argument: the post's title.
Something like this
useDpcTutorialModelPost;
useSymfonyBundleFrameworkBundleCommandContainerAwareCommand;
useSymfonyComponentConsoleInputInputArgument;
useSymfonyComponentConsoleInputInputInterface;
useSymfonyComponentConsoleOutputOutputInterface;
classCreatePostCommandextendsContainerAwareCommand
{
protectedfunctionconfigure()
{
$this
->setName('post:create')
->addArgument('title',InputArgument::REQUIRED);
}
protectedfunctionexecute(InputInterface$input,OutputInterface$output)
{
$manager=$this->getContainer()
->get('doctrine')
->getManagerForClass('DpcTutorialModelPost');
$post=newPost();
$post->setTitle($input->getArgument('title'));
$manager->persist($post);
$manager->flush();
$output->writeln('Newpostcreated:'.$post->getTitle());
}
}
Why is it inside a bundle?
Because it is automatically registered when it's in the
Commanddirectory.
So let's move it out!
Move the command to the library
Create a service for it
Give it the tag console.command.
Or else it won't be recognized anymore!
services:
dpc_tutorial.create_post_command:
class:DpcTutorialCommandCreatePostCommand
tags:
-{name:console.command}
What about ContainerAware?
It couples our command to the Symfony framework.
Which is not needed at all.
Extend from Command
Then inject dependencies instead of fetching them from the
container.
useDoctrineCommonPersistenceManagerRegistry;
classCreatePostCommandextendsCommand
{
private$doctrine;
publicfunction__construct(ManagerRegistry$doctrine)
{
parent::__construct();
$this->doctrine=$doctrine;
}
...
protectedfunctionexecute(InputInterface$input,OutputInterface$output)
{
$manager=$this->doctrine->getManager();
...
}
}
services:
dpc_tutorial.create_post_command:
class:DpcTutorialCommandCreatePostCommand
arguments:
-@doctrine
tags:
-{name:console.command}
What do we have?
Explicit dependencies
Reusable commands that works in all projects that use the
Symfony Console Component (like )
A bit less magic (no auto-registering commands)
Which means now we can put anything we want in the
Commanddirectory
Cilex
Testing a
bundle
Or: testing configuration
The Configurationclass
I don't get it!
I don't trust myself with it.
And when I don't trust myself, I write tests
SymfonyConfigTest
On GitHub: SymfonyConfigTest
{
"require-dev":{
"matthiasnoback/symfony-config-test":"~0.1"
}
}
Prepare a test suite for your
Configurationclass
Create a directory Tests/DependencyInjectioninside
the bundle.
In that directory create a new class:
ConfigurationTest.
Create the test class
The ConfigurationTestshould extend from
AbstractConfigurationTestCase
Implement the missing method getConfiguration()
namespaceDpcBundleTutorialBundleTestsDependencyInjection;
useDpcBundleTutorialBundleDependencyInjectionConfiguration;
useMatthiasSymfonyConfigTestPhpUnitAbstractConfigurationTestCase;
classConfigurationTestextendsAbstractConfigurationTestCase
{
protectedfunctiongetConfiguration()
{
returnnewConfiguration();
}
}
Desired structure in config.yml
dpc_tutorial:
#hostshouldbearequiredkey
host:localhost
A required value: host
Test first
/**
*@test
*/
publicfunctionthe_host_key_is_required()
{
$this->assertConfigurationIsInvalid(
array(
array()
),
'host'
);
}
If we provide no values at all, we expect an exception
containing "host".
See it fail
bin/phpunit-capp
Make the test pass
$rootNode
->children()
->scalarNode('host')
->isRequired()
->end()
->end();
Trial and error
You're done when the test passes!
Repeated configuration values
Desired structure in config.yml
dpc_tutorial:
servers:
a:
host:server-a.nobacksoffice.nl
port:2730
b:
host:server-b.nobacksoffice.nl
port:2730
...
hostand portare required keys for each server
configuration
Test first
/**
*@test
*/
publicfunctionhost_is_required_for_each_server()
{
$this->assertConfigurationIsInvalid(
array(
array(
'servers'=>array(
'a'=>array()
)
)
),
'host'
);
}
Run the tests
bin/phpunit-capp
Write the code
$rootNode
->children()
->arrayNode('servers')
->useAttributeAsKey('name')
->prototype('array')
->children()
->scalarNode('host')
->isRequired()
->end()
Run the tests
Test first
Repeat these steps for port
Make sure your test first fails
Then you add some code
Then the test should pass
Merging config values
$this->assertConfigurationIsInvalid(
array(
array(
...//e.g.valuesfromconfig.yml
),
array(
...//e.g.valuesfromconfig_dev.yml
)
),
'host'
);
Disable merging
Test first
/**
*@test
*/
publicfunctionserver_configurations_are_not_merged()
{
$this->assertProcessedConfigurationEquals(
array(
array(
'servers'=>array(
'a'=>array('host'=>'host-a','port'=>1)
)
),
array(
'servers'=>array(
'b'=>array('host'=>'host-b','port'=>2)
)
)
),
array(
'servers'=>array(
'b'=>array('host'=>'host-b','port'=>2)
)
)
);
}
Add some code
$rootNode
->children()
->arrayNode('servers')
->useAttributeAsKey('name')//don'treindexthearray
->prototype('array')//means:repeatable
->children()
->scalarNode('host')->end()
->scalarNode('port')->end()
->end()
->end()
->end()
->end();
Run the tests
bin/phpunit-capp
Disable deep merging
Values from different configuration sources should not be
merged.
$rootNode
->children()
->arrayNode('servers')
->performNoDeepMerging()
...
->end()
->end();
Advantages of TDD for
Configurationclasses
We gradually approach our goal.
We immediately get feedback on what's wrong.
We can test different configuration values without
changing config.ymlmanually.
We can make sure the user gets very specific error
messages about wrong configuration values.
Learn more about all the options by reading the
.
offical
documentation of the Config component
Testing Extension
classes
dpc_tutorial:
servers:
a:
host:localhost
port:2730
Should give us a dpc_tutorial.a_serverservice with
hostand portas constructor arguments.
Create a test class for your extension
Directory: Tests/DependencyInjection
Class name: [NameOfTheExtension]Test
Class should extend AbstractExtensionTestCase
Implement getContainerExtensions(): return an
instance of your extension class
namespaceDpcBundleTutorialBundleTestsDependencyInjection;
useDpcBundleTutorialBundleDependencyInjectionDpcTutorialExtension;
useMatthiasSymfonyDependencyInjectionTestPhpUnitAbstractExtensionTestCase;
classDpcTutorialExtensionTestextendsAbstractExtensionTestCase
{
protectedfunctiongetContainerExtensions()
{
returnarray(
newDpcTutorialExtension()
);
}
}
Test first
/**
*@test
*/
publicfunctionit_creates_service_definitions_for_each_server()
{
$this->load(
array(
'servers'=>array(
'a'=>array('host'=>'host-a','port'=>123),
'b'=>array('host'=>'host-b','port'=>234)
)
)
);
$this->assertContainerBuilderHasServiceDefinitionWithArgument(
'dpc_tutorial.a_server',0,'host-a'
);
$this->assertContainerBuilderHasServiceDefinitionWithArgument(
'dpc_tutorial.a_server',1,123
);
$this->assertContainerBuilderHasServiceDefinitionWithArgument(
'dpc_tutorial.b_server',0,'host-b'
);
$this->assertContainerBuilderHasServiceDefinitionWithArgument(
'dpc_tutorial.b_server',1,234
);
}
See it fail
Write the code
useSymfonyComponentDependencyInjectionDefinition;
publicfunctionload(array$configs,ContainerBuilder$container)
{
$configuration=newConfiguration();
$config=$this->processConfiguration($configuration,$configs);
foreach($config['servers']as$name=>$serverConfig){
$serverDefinition=newDefinition();
$serverDefinition->setArguments(
array(
$serverConfig['host'],
$serverConfig['port'],
)
);
$container->setDefinition(
'dpc_tutorial.'.$name.'_server',
$serverDefinition
);
}
}
See it pass
Refactor!
publicfunctionload(array$configs,ContainerBuilder$container)
{
$configuration=newConfiguration();
$config=$this->processConfiguration($configuration,$configs);
$this->configureServers($container,$config['servers']);
}
privatefunctionconfigureServers(ContainerBuilder$container,array$servers)
{
foreach($serversas$name=>$server){
$this->configureServer($container,$name,$server['host'],$server['port']);
}
}
privatefunctionconfigureServer(ContainerBuilder$container,$name,$host,$port)
{
$serverDefinition=newDefinition(null,array($host,$port));
$container->setDefinition(
'dpc_tutorial.'.$name.'_server',
$serverDefinition
);
}
Shortcuts versus the Real deal
The base class provides some useful shortcuts
To get the most out of testing your extension:
Read all about classes like Definitionin the official
documentation
Patterns of Dependency Injection
A Bundle
called Bandle
I thought a bundle is just a class that
implements BundleInterface...
Why the suffix is
necessary
abstractclassBundleextendsContainerAwareimplementsBundleInterface
{
publicfunctiongetContainerExtension()
{
...
$basename=preg_replace('/Bundle$/','',$this->getName());
$class=$this->getNamespace()
.'DependencyInjection'
.$basename
.'Extension';
if(class_exists($class)){
$extension=new$class();
...
}
...
}
}
Line 6: '/Bundle$/'
But: no need to guess, you
already know which class
it is, right?
Override the
getContainerExtension()of your
bundle class
Then make it return an instance of your extension class.
useDpcBundleTutorialBundleDependencyInjectionDpcTutorialExtension;
classDpcTutorialBundleextendsBundle
{
publicfunctiongetContainerExtension()
{
returnnewDpcTutorialExtension();
}
}
Now the extension doesn't need to be in the
DependencyInjectiondirectory anymore!
It still needs to have the Extensionsuffix though...
Open the Extensionclass (from the
HttpKernelcomponent)
Take a look at the getAlias()method.
abstractclassExtensionimplementsExtensionInterface,ConfigurationExtensionInterface
{
publicfunctiongetAlias()
{
$className=get_class($this);
if(substr($className,-9)!='Extension'){
thrownewBadMethodCallException(
'Thisextensiondoesnotfollowthenamingconvention;'
.'youmustoverwritethegetAlias()method.'
);
}
$classBaseName=substr(strrchr($className,''),1,-9);
returnContainer::underscore($classBaseName);
}
}
The alias is used to find out which configuration belongs to
which bundle:
#inconfig.yml
dpc_tutorial:
...
By convention it's the lowercase underscored bundle name.
But what happens when I
rename the bundle?
The alias changes too, which means configuration in
config.ymlwon't be recognized anymore.
Also:
The extension needs to be renamed too, because of the
naming conventions...
DpcTutorialBundle,DpcTutorialExtension,dpc_tutorial
NobackTestBundle,NobackTestExtension,noback_test
So: open your extension class
Override the getAlias()method.
Make it return the alias of your extension (a string).
E.g. DpcTutorialBundle::getAlias()returns
dpc_tutorial.
classDpcTutorialExtensionextendsExtension
{
publicfunctiongetAlias()
{
return'dpc_tutorial';
}
}
But now we have some
duplication of information
The alias is also mentioned inside the Configuration
class.
classConfigurationimplementsConfigurationInterface
{
publicfunctiongetConfigTreeBuilder()
{
$treeBuilder=newTreeBuilder();
$rootNode=$treeBuilder->root('dpc_tutorial');
...
return$treeBuilder;
}
}
Modify extension and configuration
How can we make sure that the name of the root node in
the configuration class is the same as the alias returned by
getAlias()?
classConfigurationimplementsConfigurationInterface
{
private$alias;
publicfunction__construct($alias)
{
$this->alias=$alias;
}
publicfunctiongetConfigTreeBuilder()
{
$treeBuilder=newTreeBuilder();
$rootNode=$treeBuilder->root($this->alias);
...
}
}
$configuration=newConfiguration($this->getAlias());
This introduces a bug
Run app/console config:dump-reference
[extension-alias]
Open the Extensionclass
Take the one from the DependencyInjection
component.
publicfunctiongetConfiguration(array$config,ContainerBuilder$container)
{
$reflected=newReflectionClass($this);
$namespace=$reflected->getNamespaceName();
$class=$namespace.'Configuration';
if(class_exists($class)){
$r=newReflectionClass($class);
$container->addResource(newFileResource($r->getFileName()));
if(!method_exists($class,'__construct')){
$configuration=new$class();
return$configuration;
}
}
}
Our Configurationclass has a constructor...
Override getConfiguration()in
your extension
Also: make sure only one instance of Configurationis
created in the extension class.
classDpcTutorialExtensionextendsExtension
{
publicfunctionload(array$configs,ContainerBuilder$container)
{
$configuration=$this->getConfiguration($configs,$container);
$config=$this->processConfiguration($configuration,$configs);
...
}
publicfunctiongetConfiguration(array$config,ContainerBuilder$container)
{
returnnewConfiguration($this->getAlias());
}
...
}
Now we are allowed to rename Configurationor put it
somewhere else entirely!
Some last improvement
Extend from ConfigurableExtension.
abstractclassConfigurableExtensionextendsExtension
{
finalpublicfunctionload(array$configs,ContainerBuilder$container)
{
$this->loadInternal(
$this->processConfiguration(
$this->getConfiguration($configs,$container),
$configs
),
$container
);
}
abstractprotectedfunctionloadInternal(array$mergedConfig,ContainerBuilder$conta
}
It will save you a call to processConfiguration().
classDpcTutorialExtensionextendsConfigurableExtension
{
publicfunctionloadInternal(array$mergedConfig,ContainerBuilder$container)
{
//$mergedConfighasalreadybeenprocessed
$loader=newXmlFileLoader($container,newFileLocator(__DIR__.'/../Resources/co
$loader->load('services.xml');
}
...
}
We introduced flexibility...
By hard-coding the alias
And by skipping all the magic stuff
Now we can
Change *Bundleinto *Bandle
Change *Extensioninto *Plugin
Change Configurationinto Complexity
If we want...
€ 15,00
I’m impressed. — Robert C. Martin
leanpub.com/principles-of-php-package-design/c/dpc2014
Feedback
joind.in/10849
Twitter
@matthiasnoback

High Quality Symfony Bundles tutorial - Dutch PHP Conference 2014