142
The Naked Bundle Matthias Noback

High Quality Symfony Bundles tutorial - Dutch PHP Conference 2014

Embed Size (px)

Citation preview

The Naked BundleMatthias Noback

Assuming you all have aworking project

https://github.com/matthiasnoback/high-quality-bundles-project

Generate a bundleUse app/console generate:bundle

Namespace: Dpc/Bundle/TutorialBundleBundle name: DpcTutorialBundleConfiguration: ymlWhole directory structure: yes

The full directory structure of a bundle:

What's wrong?Too many commentsRouting and a controllerTranslationsTwig templatesA useless test

You are not going to use it all,but it will be committed!

Before we continue, clean up yourbundle

Remove the following files and directories:ControllerResources/docResources/publicResources/translationsResources/viewsTests

Also remove any superfluous comments!

The officialview onbundles

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 abundle

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 livesinside a bundle

Documentation » Glossary

Which is not really true, because many things live insidelibraries (e.g. the Symfony components), which is good.

Best practicesDocumentation » 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 isnot sufficient anymorePeople are reimplementing things because existing

solutions are too tightly coupled to a framework (or even aspecific version).

Why is it necessary to do all these things again for Symfony,Laravel, Zend, CodeIgniter, CakePHP, etc.?

Last year I started workingon this

Then it became this

About bundles

A bundle is...A thin layer of Framework-specific

configuration to make resources from somelibrary available in a Symfony2 application.

A "Symfony application"meaning:

A project that depends on the Symfony FrameworkBundle.

Resources areRoutes (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 resourcesavailable, the rest is elsewhere in a library.

I also wrote

The challengeMake the bundle as clean as possible

Entities

Create an entityUse app/console doctrine:generate:entity

Specs

The entity shortcut name: DpcTutorialBundle:Post.Configuration format: annotationIt has a title (string) field.Run app/console doctrine:schema:create orupdate --force and make sure your entity has acorresponding table in your database.

Let's say you've modelled the Postentity 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 Post class to Doctrine ORM.

(Since annotations are classes!)

Also: why are my entities inside abundle?

They are not only useful inside a Symfony project.

Move the entity to anothernamespace

E.g. Dpc\Tutorial\Model\Post.

Create an XML mapping fileE.g. Dpc\Tutorial\Model\Mapping\Post.orm.xml<doctrine-mapping xmlns="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-mapping http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd"> <entity name="Dpc\Tutorial\Model\Post"> <id name="id" type="integer"> <generator strategy="AUTO"/> </id> <field name="title" type="string"/> </entity></doctrine-mapping>

You can copy the basic XML from/vendor/doctrine/orm/docs/en/reference/xml-

mapping.rst.

In factAlways use XML mapping, it makes a lot of sense, and you

get auto-completion in your IDE!

Remove all ORM things (annotations) from the Post class

If you are going to try the following at home:

Update DoctrineBundleModify composer.json:

{ "require": { ... "doctrine/doctrine-bundle": "~1.2@dev" }}

Run composer update doctrine/doctrine-bundle

Add a compiler pass to your bundleIt will load the XML mapping files

use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DoctrineOrmMappingsPass;

class DpcTutorialBundle{ public function build(ContainerBuilder $container) { $container->addCompilerPass($this->buildMappingCompilerPass()); }

private function buildMappingCompilerPass() { return DoctrineOrmMappingsPass::createXmlMappingDriver( array( __DIR__ . '/../../Test/Model/Mapping/' => 'Dpc\Tutorial\Model' ) ); }}

What have we won?Clean model classesThey are reusable in non-Symfony projectsThey are reusable with different persistence libraries

Documentation » The Cookbook » Doctrine » How to provide model classes for several Doctrineimplementations

Controllers

Create a controllerUse app/console generate:controller

Specs

Name: DpcTutorialBundle:PostConfiguration: annotationTemplate: twigThe route contains an id parameter.Action: showActionRoute: /post/{id}/show

Implement the following logicModify the action to retrieve a Post entity from the

database:public function showAction(Post $post){ return array('post' => $post);}

Don't forget to register the route# in the bundle's routing.yml file:DpcTutorialBundle_Controllers: resource: "@DpcTutorialBundle/Controller" type: "annotation"

By the wayConsider using XML for routing too!

For the same reasons

Does all of this really needto be inside the bundle?

Move the controller class to thelibrary

Remove parent Controller classWe are going to inject every dependency by hand instead of

relying on the service container.

Create a service for the controllerservices: dpc_tutorial.post_controller: class: Dpc\Tutorial\Controller\PostController

Remove @Route annotationsInstead: 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 @Template annotationsInject the templating service instead and use it to render

the template.use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Templating\EngineInterface;

class PostController{ public function __construct(EngineInterface $templating) { $this->templating = $templating; }

public function showAction(Post $post) { return new Response( $this->templating->render( 'DpcTutorialBundle:Post:show.html.twig', array('post' => $post) ) ); }}

services: dpc_tutorial.post_controller: class: Dpc\Tutorial\Controller\PostController arguments: - @templating

What about the

Templates

Move the template to the libraryE.g. from Dpc/Bundle/TutorialBundle/Resources/views/Post/show.html.twig to

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 thetemplates

# in config.ymltwig: ... 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 prependconfiguration

use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;

class DpcTutorialExtension extends ConfigurableExtension implements PrependExtensionInterface{ ... public function prepend(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 $post argument relies on something called

.param convertersThose convert the id from the route to the actual Post

entity.This is actually Symfony framework-specific behavior

Rewrite the controller to make useof a repository

use Doctrine\Common\Persistence\ObjectRepository;

class PostController{ public function __construct(..., ObjectRepository $postRepository) { ... $this->postRepository = $postRepository; }

public function showAction($id) { $post = $this->postRepository->find($id); if (!($post instanceof Post)) { throw new NotFoundHttpException(); } ... }}

services: dpc_tutorial.post_controller: class: Dpc\Tutorial\Controller\PostController arguments: - @templating - @dpc_tutorial.post_repository

dpc_tutorial.post_repository: class: Doctrine\Common\Persistence\ObjectRepository factory_service: doctrine factory_method: getRepository arguments: - Dpc\Tutorial\Model\Post

What do we have now?

Reusable templates

Reusable controllersThey work with Silex too!

Who would have though that was possible?

Console commands

Create a console commandUse app/console generate:console-command

Make it insert a new post in the database.It takes one argument: the post's title.

Something like thisuse Dpc\Tutorial\Model\Post;use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;use Symfony\Component\Console\Input\InputArgument;use Symfony\Component\Console\Input\InputInterface;use Symfony\Component\Console\Output\OutputInterface;

class CreatePostCommand extends ContainerAwareCommand{ protected function configure() { $this ->setName('post:create') ->addArgument('title', InputArgument::REQUIRED); }

protected function execute(InputInterface $input, OutputInterface $output) { $manager = $this->getContainer() ->get('doctrine') ->getManagerForClass('Dpc\Tutorial\Model\Post');

$post = new Post(); $post->setTitle($input->getArgument('title')); $manager->persist($post); $manager->flush();

$output->writeln('New post created: '.$post->getTitle()); }}

Why is it inside a bundle?Because it is automatically registered when it's in the

Command directory.

So let's move it out!

Move the command to the library

Create a service for itGive it the tag console.command.

Or else it won't be recognized anymore!

services: dpc_tutorial.create_post_command: class: Dpc\Tutorial\Command\CreatePostCommand tags: - { name: console.command }

What about ContainerAware?It couples our command to the Symfony framework.

Which is not needed at all.

Extend from CommandThen inject dependencies instead of fetching them from the

container.use Doctrine\Common\Persistence\ManagerRegistry;

class CreatePostCommand extends Command{ private $doctrine;

public function __construct(ManagerRegistry $doctrine) { parent::__construct();

$this->doctrine = $doctrine; } ... protected function execute(InputInterface $input, OutputInterface $output) { $manager = $this->doctrine->getManager(); ... }}

services: dpc_tutorial.create_post_command: class: Dpc\Tutorial\Command\CreatePostCommand arguments: - @doctrine tags: - { name: console.command }

What do we have?Explicit dependenciesReusable commands that works in all projects that use theSymfony Console Component (like )A bit less magic (no auto-registering commands)Which means now we can put anything we want in theCommand directory

Cilex

Testing abundle

Or: testing configuration

The Configuration class

I don't get it!

I don't trust myself with it.And when I don't trust myself, I write tests

SymfonyConfigTestOn GitHub: SymfonyConfigTest

{ "require-dev": { "matthiasnoback/symfony-config-test": "~0.1" }}

Prepare a test suite for yourConfiguration class

Create a directory Tests/DependencyInjection insidethe bundle.In that directory create a new class:ConfigurationTest.

Create the test classThe ConfigurationTest should extend fromAbstractConfigurationTestCaseImplement the missing method getConfiguration()namespace Dpc\Bundle\TutorialBundle\Tests\DependencyInjection;

use Dpc\Bundle\TutorialBundle\DependencyInjection\Configuration;use Matthias\SymfonyConfigTest\PhpUnit\AbstractConfigurationTestCase;

class ConfigurationTest extends AbstractConfigurationTestCase{ protected function getConfiguration() { return new Configuration(); }}

Desired structure in config.ymldpc_tutorial: # host should be a required key host: localhost

A required value: hostTest first

/** * @test */public function the_host_key_is_required(){ $this->assertConfigurationIsInvalid( array( array() ), 'host' );}

If we provide no values at all, we expect an exceptioncontaining "host".

See it failbin/phpunit -c app

Make the test pass$rootNode ->children() ->scalarNode('host') ->isRequired() ->end() ->end();

Trial and errorYou're done when the test passes!

Repeated configuration valuesDesired structure in config.yml

dpc_tutorial: servers: a: host: server-a.nobacksoffice.nl port: 2730 b: host: server-b.nobacksoffice.nl port: 2730 ...

host and port are required keys for each serverconfiguration

Test first/** * @test */public function host_is_required_for_each_server(){ $this->assertConfigurationIsInvalid( array( array( 'servers' => array( 'a' => array() ) ) ), 'host' );}

Run the testsbin/phpunit -c app

Write the code$rootNode ->children() ->arrayNode('servers') ->useAttributeAsKey('name') ->prototype('array') ->children() ->scalarNode('host') ->isRequired() ->end()

Run the tests

Test firstRepeat these steps for port

Make sure your test first failsThen you add some codeThen the test should pass

Merging config values$this->assertConfigurationIsInvalid( array( array( ... // e.g. values from config.yml ), array( ... // e.g. values from config_dev.yml ) ), 'host');

Disable mergingTest first

/** * @test */public function server_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't reindex the array ->prototype('array') // means: repeatable ->children() ->scalarNode('host')->end() ->scalarNode('port')->end() ->end() ->end() ->end() ->end();

Run the testsbin/phpunit -c app

Disable deep mergingValues from different configuration sources should not be

merged.$rootNode ->children() ->arrayNode('servers') ->performNoDeepMerging() ... ->end() ->end();

Advantages of TDD forConfiguration classes

We gradually approach our goal.We immediately get feedback on what's wrong.We can test different configuration values withoutchanging config.yml manually.We can make sure the user gets very specific errormessages about wrong configuration values.Learn more about all the options by reading the

.offical

documentation of the Config component

Testing Extensionclasses

dpc_tutorial: servers: a: host: localhost port: 2730

Should give us a dpc_tutorial.a_server service withhost and port as constructor arguments.

Create a test class for your extensionDirectory: Tests/DependencyInjectionClass name: [NameOfTheExtension]TestClass should extend AbstractExtensionTestCaseImplement getContainerExtensions(): return aninstance of your extension classnamespace Dpc\Bundle\TutorialBundle\Tests\DependencyInjection;

use Dpc\Bundle\TutorialBundle\DependencyInjection\DpcTutorialExtension;use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase;

class DpcTutorialExtensionTest extends AbstractExtensionTestCase{ protected function getContainerExtensions() { return array( new DpcTutorialExtension() ); }}

Test first/** * @test */public function it_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 codeuse Symfony\Component\DependencyInjection\Definition;

public function load(array $configs, ContainerBuilder $container){ $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs);

foreach ($config['servers'] as $name => $serverConfig) { $serverDefinition = new Definition(); $serverDefinition->setArguments( array( $serverConfig['host'], $serverConfig['port'], ) );

$container->setDefinition( 'dpc_tutorial.' . $name . '_server', $serverDefinition ); }}

See it pass

Refactor!public function load(array $configs, ContainerBuilder $container){ $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs);

$this->configureServers($container, $config['servers']);}

private function configureServers(ContainerBuilder $container, array $servers){ foreach ($servers as $name => $server) { $this->configureServer($container, $name, $server['host'], $server['port']); }}

private function configureServer(ContainerBuilder $container, $name, $host, $port){ $serverDefinition = new Definition(null, array($host, $port)); $container->setDefinition( 'dpc_tutorial.' . $name . '_server', $serverDefinition );}

Shortcuts versus the Real dealThe base class provides some useful shortcutsTo get the most out of testing your extension:Read all about classes like Definition in the officialdocumentation

Patterns of Dependency Injection

A Bundlecalled Bandle

I thought a bundle is just a class thatimplements BundleInterface...

Why the suffix isnecessary

abstract class Bundle extends ContainerAware implements BundleInterface{ public function getContainerExtension() { ... $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, youalready know which class

it is, right?

Override thegetContainerExtension() of your

bundle classThen make it return an instance of your extension class.

use Dpc\Bundle\TutorialBundle\DependencyInjection\DpcTutorialExtension;

class DpcTutorialBundle extends Bundle{ public function getContainerExtension() { return new DpcTutorialExtension(); }}

Now the extension doesn't need to be in theDependencyInjection directory anymore!

It still needs to have the Extension suffix though...

Open the Extension class (from theHttpKernel component)Take a look at the getAlias() method.

abstract class Extension implements ExtensionInterface, ConfigurationExtensionInterface{ public function getAlias() { $className = get_class($this); if (substr($className, -9) != 'Extension') { throw new BadMethodCallException( 'This extension does not follow the naming convention;' . 'you must overwrite the getAlias() method.' ); } $classBaseName = substr(strrchr($className, '\\'), 1, -9);

return Container::underscore($classBaseName); }}

The alias is used to find out which configuration belongs towhich bundle:

# in config.ymldpc_tutorial: ...

By convention it's the lowercase underscored bundle name.

But what happens when Irename the bundle?

The alias changes too, which means configuration inconfig.yml won'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 classOverride the getAlias() method.

Make it return the alias of your extension (a string).E.g. DpcTutorialBundle::getAlias() returns

dpc_tutorial.

class DpcTutorialExtension extends Extension{ public function getAlias() { return 'dpc_tutorial'; }}

But now we have someduplication of information

The alias is also mentioned inside the Configurationclass.

class Configuration implements ConfigurationInterface{ public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root('dpc_tutorial'); ...

return $treeBuilder; }}

Modify extension and configurationHow can we make sure that the name of the root node inthe configuration class is the same as the alias returned by

getAlias()?

class Configuration implements ConfigurationInterface{ private $alias;

public function __construct($alias) { $this->alias = $alias; }

public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root($this->alias); ... }}

$configuration = new Configuration($this->getAlias());

This introduces a bugRun app/console config:dump-reference

[extension-alias]

Open the Extension classTake the one from the DependencyInjection

component.

public function getConfiguration(array $config, ContainerBuilder $container){ $reflected = new \ReflectionClass($this); $namespace = $reflected->getNamespaceName(); $class = $namespace.'\\Configuration'; if (class_exists($class)) { $r = new \ReflectionClass($class); $container->addResource(new FileResource($r->getFileName()));

if (!method_exists($class, '__construct')) { $configuration = new $class();

return $configuration; } }}

Our Configuration class has a constructor...

Override getConfiguration() inyour extension

Also: make sure only one instance of Configuration iscreated in the extension class.

class DpcTutorialExtension extends Extension{ public function load(array $configs, ContainerBuilder $container) { $configuration = $this->getConfiguration($configs, $container); $config = $this->processConfiguration($configuration, $configs); ... }

public function getConfiguration(array $config, ContainerBuilder $container) { return new Configuration($this->getAlias()); } ...}

Now we are allowed to rename Configuration or put itsomewhere else entirely!

Some last improvementExtend from ConfigurableExtension.

abstract class ConfigurableExtension extends Extension{ final public function load(array $configs, ContainerBuilder $container) { $this->loadInternal( $this->processConfiguration( $this->getConfiguration($configs, $container), $configs ), $container ); }

abstract protected function loadInternal(array $mergedConfig, ContainerBuilder $container}

It will save you a call to processConfiguration().

class DpcTutorialExtension extends ConfigurableExtension{ public function loadInternal(array $mergedConfig, ContainerBuilder $container) { // $mergedConfig has already been processed

$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config' $loader->load('services.xml'); } ...}

We introduced flexibility...By hard-coding the alias

And by skipping all the magic stuff

Now we canChange *Bundle into *Bandle

Change *Extension into *PluginChange Configuration into Complexity

If we want...

€ 15,00

I’m impressed. — Robert C. Martin

leanpub.com/principles-of-php-package-design/c/dpc2014

Feedbackjoind.in/10849

Twitter@matthiasnoback