High Quality Symfony Bundles tutorial - Dutch PHP Conference 2014
Upcoming SlideShare
Loading in...5

Like this? Share it with your network


High Quality Symfony Bundles tutorial - Dutch PHP Conference 2014



Slides for my talk "High Quality Symfony Bundles" tutorial at the Dutch PHP Conference 2014 (http://phpconference.nl).

Slides for my talk "High Quality Symfony Bundles" tutorial at the Dutch PHP Conference 2014 (http://phpconference.nl).



Total Views
Views on SlideShare
Embed Views



3 Embeds 172

https://twitter.com 149
http://librosweb.es 22
https://www.linkedin.com 1



Upload Details

Uploaded via as Adobe PDF

Usage Rights

© All Rights Reserved

Report content

Flagged as inappropriate Flag as inappropriate
Flag as inappropriate

Select your reason for flagging this presentation as inappropriate.

  • Full Name Full Name Comment goes here.
    Are you sure you want to
    Your message goes here
  • Thanks for your kind words.
    Replacing the entity with the id is in my opinion always (at least in controllers) a best practice, even when you're not trying to achieve a 'naked bundle'. In some cases you don't actually need the entire entity object, just its ID. For instance: POST /article/{id}/comment would only need some code like this:

    $article = $this->entityManager->getReference($articleId);
    // create comment, link it to the article
    $comment = new Comment(..., $article);
    Are you sure you want to
    Your message goes here
  • Very interesting and informative. There is one point I feel I disagree with though, and that is replacing requiring an entity (or model) with requiring the ID, and using a repository to find the model based on the ID. I would argue that that is a code smell, and should require the actual object, and it should be up to the developer to decide how the argument gets passed, either with ParamConverters in Symfony, Route::convert in Silex, or another implementation. What are your thoughts on this?
    Are you sure you want to
    Your message goes here
  • Thanks!
    Are you sure you want to
    Your message goes here
  • slide 65 , seems a typo to me. Who would have `thought` right ?
    Are you sure you want to
    Your message goes here
Post Comment
Edit your comment

High Quality Symfony Bundles tutorial - Dutch PHP Conference 2014 Presentation Transcript

  • 1. The Naked Bundle Matthias Noback
  • 2. Assuming you all have a working project https://github.com/matthiasnoback/ high-quality-bundles-project
  • 3. Generate a bundle Use app/console generate:bundle Namespace: Dpc/Bundle/TutorialBundle Bundle name: DpcTutorialBundle Configuration: yml Whole directory structure: yes
  • 4. The full directory structure of a bundle:
  • 5. What's wrong? Too many comments Routing and a controller Translations Twig templates A useless test
  • 6. You are not going to use it all, but it will be committed!
  • 7. 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!
  • 8. The official view on bundles
  • 9. First-class citizens Documentation » The Quick Tour » The Architecture
  • 10. I think your code is more important than the framework, which should be considered an implementation detail.
  • 11. All your code lives in a bundle Documentation » The Book » Creating Pages in Symfony2
  • 12. I don't think that's a good idea. It contradicts the promise of reuse of "pre-built feature packages".
  • 13. Almost everything lives inside a bundle Documentation » Glossary
  • 14. Which is not really true, because many things live inside libraries (e.g. the Symfony components), which is good.
  • 15. Best practices Documentation » Cookbook » Bundles
  • 16. Controllers Controllers don't need to extend anything at all. ContainerAware*should be avoided in all cases.
  • 17. Tests What's up with the 95%?
  • 18. Twig Why Twig? I though Symfony didn't care about this. Documentation » The Book » Creating and Using Templates
  • 19. 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.?
  • 20. Last year I started working on this
  • 21. Then it became this
  • 22. About bundles
  • 23. A bundle is... A thin layer of Framework-specific configuration to make resources from some library available in a Symfony2 application.
  • 24. A "Symfony application" meaning: A project that depends on the Symfony FrameworkBundle.
  • 25. 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) ...?
  • 26. So: a bundle is mainly configuration to make these resources available, the rest is elsewhere in a library.
  • 27. I also wrote
  • 28. The challenge Make the bundle as clean as possible
  • 29. Entities
  • 30. 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.
  • 31. 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!
  • 32. Why? Annotations couple the Postclass to Doctrine ORM. (Since annotations are classes!)
  • 33. Also: why are my entities inside a bundle? They are not only useful inside a Symfony project.
  • 34. Move the entity to another namespace E.g. DpcTutorialModelPost.
  • 35. 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.
  • 36. In fact Always use XML mapping, it makes a lot of sense, and you get auto-completion in your IDE!
  • 37. Remove all ORM things (annotations) from the Postclass
  • 38. 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
  • 39. 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' ) ); } }
  • 40. 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
  • 41. Controllers
  • 42. 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
  • 43. Implement the following logic Modify the action to retrieve a Postentity from the database: publicfunctionshowAction(Post$post) { returnarray('post'=>$post); }
  • 44. Don't forget to register the route #inthebundle'srouting.ymlfile: DpcTutorialBundle_Controllers: resource:"@DpcTutorialBundle/Controller" type:"annotation"
  • 45. By the way Consider using XML for routing too! For the same reasons
  • 46. Does all of this really need to be inside the bundle?
  • 47. Move the controller class to the library
  • 48. Remove parent Controllerclass We are going to inject every dependency by hand instead of relying on the service container.
  • 49. Create a service for the controller services: dpc_tutorial.post_controller: class:DpcTutorialControllerPostController
  • 50. 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
  • 51. 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) ) ); } }
  • 52. services: dpc_tutorial.post_controller: class:DpcTutorialControllerPostController arguments: -@templating
  • 53. What about the Templates
  • 54. 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
  • 55. Change the template reference $this->templating->render( '@DpcTutorial/Post/show.html.twig', array('post'=>$post) )
  • 56. 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
  • 57. Well... We don't want to ask users to modify their config.yml!
  • 58. 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
  • 59. 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
  • 60. 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(); } ... } }
  • 61. 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
  • 62. What do we have now?
  • 63. Reusable templates
  • 64. Reusable controllers They work with Silex too! Who would have though that was possible?
  • 65. Console commands
  • 66. 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.
  • 67. 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()); } }
  • 68. Why is it inside a bundle? Because it is automatically registered when it's in the Commanddirectory.
  • 69. So let's move it out!
  • 70. Move the command to the library
  • 71. 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}
  • 72. What about ContainerAware? It couples our command to the Symfony framework. Which is not needed at all.
  • 73. 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(); ... } }
  • 74. services: dpc_tutorial.create_post_command: class:DpcTutorialCommandCreatePostCommand arguments: -@doctrine tags: -{name:console.command}
  • 75. 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
  • 76. Testing a bundle Or: testing configuration
  • 77. The Configurationclass
  • 78. I don't get it!
  • 79. I don't trust myself with it. And when I don't trust myself, I write tests
  • 80. SymfonyConfigTest On GitHub: SymfonyConfigTest { "require-dev":{ "matthiasnoback/symfony-config-test":"~0.1" } }
  • 81. Prepare a test suite for your Configurationclass Create a directory Tests/DependencyInjectioninside the bundle. In that directory create a new class: ConfigurationTest.
  • 82. Create the test class The ConfigurationTestshould extend from AbstractConfigurationTestCase Implement the missing method getConfiguration() namespaceDpcBundleTutorialBundleTestsDependencyInjection; useDpcBundleTutorialBundleDependencyInjectionConfiguration; useMatthiasSymfonyConfigTestPhpUnitAbstractConfigurationTestCase; classConfigurationTestextendsAbstractConfigurationTestCase { protectedfunctiongetConfiguration() { returnnewConfiguration(); } }
  • 83. Desired structure in config.yml dpc_tutorial: #hostshouldbearequiredkey host:localhost
  • 84. 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".
  • 85. See it fail bin/phpunit-capp
  • 86. Make the test pass $rootNode ->children() ->scalarNode('host') ->isRequired() ->end() ->end();
  • 87. Trial and error You're done when the test passes!
  • 88. 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
  • 89. Test first /** *@test */ publicfunctionhost_is_required_for_each_server() { $this->assertConfigurationIsInvalid( array( array( 'servers'=>array( 'a'=>array() ) ) ), 'host' ); }
  • 90. Run the tests bin/phpunit-capp
  • 91. Write the code $rootNode ->children() ->arrayNode('servers') ->useAttributeAsKey('name') ->prototype('array') ->children() ->scalarNode('host') ->isRequired() ->end()
  • 92. Run the tests
  • 93. Test first Repeat these steps for port Make sure your test first fails Then you add some code Then the test should pass
  • 94. Merging config values $this->assertConfigurationIsInvalid( array( array( ...//e.g.valuesfromconfig.yml ), array( ...//e.g.valuesfromconfig_dev.yml ) ), 'host' );
  • 95. 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) ) ) ); }
  • 96. 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();
  • 97. Run the tests bin/phpunit-capp
  • 98. Disable deep merging Values from different configuration sources should not be merged. $rootNode ->children() ->arrayNode('servers') ->performNoDeepMerging() ... ->end() ->end();
  • 99. 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
  • 100. Testing Extension classes dpc_tutorial: servers: a: host:localhost port:2730 Should give us a dpc_tutorial.a_serverservice with hostand portas constructor arguments.
  • 101. 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() ); } }
  • 102. 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 ); }
  • 103. See it fail
  • 104. 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 ); } }
  • 105. See it pass
  • 106. 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 ); }
  • 107. 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
  • 108. Patterns of Dependency Injection
  • 109. A Bundle called Bandle
  • 110. I thought a bundle is just a class that implements BundleInterface...
  • 111. 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$/'
  • 112. But: no need to guess, you already know which class it is, right?
  • 113. Override the getContainerExtension()of your bundle class Then make it return an instance of your extension class.
  • 114. useDpcBundleTutorialBundleDependencyInjectionDpcTutorialExtension; classDpcTutorialBundleextendsBundle { publicfunctiongetContainerExtension() { returnnewDpcTutorialExtension(); } } Now the extension doesn't need to be in the DependencyInjectiondirectory anymore!
  • 115. It still needs to have the Extensionsuffix though...
  • 116. Open the Extensionclass (from the HttpKernelcomponent) Take a look at the getAlias()method.
  • 117. 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); } }
  • 118. 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.
  • 119. But what happens when I rename the bundle? The alias changes too, which means configuration in config.ymlwon't be recognized anymore.
  • 120. Also: The extension needs to be renamed too, because of the naming conventions... DpcTutorialBundle,DpcTutorialExtension,dpc_tutorial NobackTestBundle,NobackTestExtension,noback_test
  • 121. 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.
  • 122. classDpcTutorialExtensionextendsExtension { publicfunctiongetAlias() { return'dpc_tutorial'; } }
  • 123. But now we have some duplication of information The alias is also mentioned inside the Configuration class.
  • 124. classConfigurationimplementsConfigurationInterface { publicfunctiongetConfigTreeBuilder() { $treeBuilder=newTreeBuilder(); $rootNode=$treeBuilder->root('dpc_tutorial'); ... return$treeBuilder; } }
  • 125. 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()?
  • 126. classConfigurationimplementsConfigurationInterface { private$alias; publicfunction__construct($alias) { $this->alias=$alias; } publicfunctiongetConfigTreeBuilder() { $treeBuilder=newTreeBuilder(); $rootNode=$treeBuilder->root($this->alias); ... } } $configuration=newConfiguration($this->getAlias());
  • 127. This introduces a bug Run app/console config:dump-reference [extension-alias]
  • 128. Open the Extensionclass Take the one from the DependencyInjection component.
  • 129. 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...
  • 130. Override getConfiguration()in your extension Also: make sure only one instance of Configurationis created in the extension class.
  • 131. 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!
  • 132. Some last improvement Extend from ConfigurableExtension.
  • 133. abstractclassConfigurableExtensionextendsExtension { finalpublicfunctionload(array$configs,ContainerBuilder$container) { $this->loadInternal( $this->processConfiguration( $this->getConfiguration($configs,$container), $configs ), $container ); } abstractprotectedfunctionloadInternal(array$mergedConfig,ContainerBuilder$conta }
  • 134. It will save you a call to processConfiguration().
  • 135. classDpcTutorialExtensionextendsConfigurableExtension { publicfunctionloadInternal(array$mergedConfig,ContainerBuilder$container) { //$mergedConfighasalreadybeenprocessed $loader=newXmlFileLoader($container,newFileLocator(__DIR__.'/../Resources/co $loader->load('services.xml'); } ... }
  • 136. 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...
  • 137. € 15,00
  • 138. I’m impressed. — Robert C. Martin leanpub.com/principles-of-php-package-design/c/dpc2014
  • 139. Feedback joind.in/10849 Twitter @matthiasnoback