mmoreram/base-bundle

Base Bundle for all standard Symfony Bundles

Installs: 34 822

Dependents: 9

Suggesters: 0

Stars: 21

Watchers: 4

Forks: 4

Open Issues: 3

Type:symfony-bundle

1.0.12 2019-05-11 14:57 UTC

README

Build Status

The minimum requirements of this bundle is PHP 7.1 and Symfony 3.2 because the bundle is using features on both versions. If you're not using them yet, I encourage you to do it.

About dependencies

This bundle has multiple dependencies, but, and because the bundle is not providing any extra feature, but a set of helpers on top of existing projects and parts of the Symfony environment, and because all these features belong to very different parts of this environment, we've removed all these dependencies. We think that these dependencies should be already resolved before loading this bundle, so by default, the package is properly isolated.

Make sure that these dependencies are installed, or install them in your project.

About the content

This bundle aims to be the base for all bundles in your Symfony project. Know about these three big blocks.

Bundles

Functional Tests

Entity mapping

Bundle extension

All bundles in Symfony should start with a PHP class, the Bundle class. This class should always implement the interface Symfony\Component\HttpKernel\Bundle\BundleInterface, but as you know Symfony always try to make things easy, so you can simply extend the base implementation of a bundle.

use Symfony\Component\HttpKernel\Bundle\Bundle;

/**
 * My bundle
 */
final class MyBundle extends Bundle
{

}

I've personally defended the magic behind some parts of the Framework, but you should always know what is that magic and discover how affect in your project. Let me explain a little bit your bundle behavior with this implementation.

Bundle dependencies

When we talk about dependencies we are used to talking about PHP dependencies. If we use a file, then this file should be inside our vendor folder, right? That sounds great, but what about if a bundle needs another bundle to be instanced as well in our kernel? How Symfony is supposed to handle this need?

Well, the project itself is not providing this feature at this moment, but even if the theory says that a bundle should never have an external bundle dependency, the reality is another one, and as far as I know, implementations cover mostly real problems not nice theories.

Let's check Symfony Bundle Dependencies. By using this BaseBundle, your bundle has automatically dependencies (by default, none).

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Mmoreram\SymfonyBundleDependencies\DependentBundleInterface;

/**
 * Class AbstractBundle.
 */
abstract class BaseBundle extends Bundle implements DependentBundleInterface
{
    //...

    /**
     * Create instance of current bundle, and return dependent bundle namespaces
     *
     * @return array Bundle instances
     */
    public static function getBundleDependencies(KernelInterface $kernel)
    {
        return [];
    }
}

If your bundle has dependencies, feel free to overwrite this method in your class and add them all. Take a look at the main library documentation to learn a bit more about how to work with dependencies in your Kernel.

Extension declaration

First of all, your extension will be loaded by magic. What does it mean? Well, the framework will look for your extension following an standard (the Symfony one). But what happens if your extension (by error or explicitly) doesn't follow this standard?

Well, nothing will happen. The framework will still looking for a non-existing class and your desired class will never be instanced. You will spend then some valuable time finding out where the problem is.

First step to do in your project: avoid this magic and define always your extension by instancing it in your bundle.

use Symfony\Component\HttpKernel\Bundle\Bundle;

/**
 * My bundle
 */
final class MyBundle extends Bundle
{
    /**
     * Returns the bundle's container extension.
     *
     * @return ExtensionInterface|null The container extension
     *
     * @throws \LogicException
     */
    public function getContainerExtension()
    {
        return new MyExtension($this);
    }
}

As you can see, your extensions will require the bundle itself as the first and only construct parameter. Check the configuration chapter to know why.

Even this is the default behavior you can be more explicit and overwrite this method to define that your bundle is not using any extension. That will help you to comprehend a little bit more your bundle requirements.

use Symfony\Component\HttpKernel\Bundle\Bundle;

/**
 * My bundle
 */
final class MyBundle extends Bundle
{
    /**
     * Returns the bundle's container extension.
     *
     * @return ExtensionInterface|null The container extension
     *
     * @throws \LogicException
     */
    public function getContainerExtension()
    {
        return null;
    }
}

Compiler Pass declaration

One of the most unknown Symfony features is the Compiler Pass. If you want to know a little bit about what are they and how to use them, take a look at the fantastic cookbook How to work with Compiler Passes in bundles.

You can instance your Compiler Passes by using the build method inside your bundle as you can see in this example.

use Symfony\Component\HttpKernel\Bundle\Bundle;

/**
 * My bundle
 */
final class MyBundle extends Bundle
{
    /**
     * Builds bundle.
     *
     * @param ContainerBuilder $container Container
     */
    public function build(ContainerBuilder $container)
    {
        parent::build($container);

        /**
         * Adds Compiler Passes.
         */
        $container->addCompilerPass(new MyCompilerPass());
    }
}

Let's make it easier. Use the BaseBundle and you will be able to use the getCompilerPasses method in order to define all your compiler passes.

use Mmoreram\BaseBundle\BaseBundle;

/**
 * My bundle
 */
final class MyBundle extends BaseBundle
{
    /**
     * Register compiler passes
     *
     * @return CompilerPassInterface[]
     */
    public function getCompilerPasses()
    {
        return [
            new MyCompilerPass(),
        ];
    }
}

Commands declaration

A bundle is also responsible to expose all commands into the main application. Magic is here as well, so all files ending with Command and extending Command or ContainerAwareCommand inside the main folder Command will be instanced and loaded each time the bundle is instanced.

Same rationale than the Extension one. You're responsible to know where are your classes, and the bundle should know it in a very explicit way.

By default, this BaseBundle abstract class removes the Command autoload, allowing you, in your main Bundle class, to return an array of Command instances. By default, this method returns empty array.

/**
 * Class AbstractBundle.
 */
abstract class BaseBundle extends Bundle
{
    // ...

    /**
     * Get command instance array
     *
     * @return Command[]
     */
    public function getCommands() : array
    {
        return [];
    }

    // ...
}

I highly recommend you to never use Commands with this kind of magic, as commands should be, as Controllers and EventListeners, only an entry point to your domain. You can define your commands as services, injecting there all you need to make it work.

How to define commands as services

SimpleBaseBundle

Even simpler.

Symfony should provide a RAD infrastructure that, in case you want to create a rapid bundle exposing an essential parts to the framework, didn't make you spend too much time and effort on that.

So, for your RAD applications, do you really think you need more than one single class to create a simple bundle? Not at all. Not anymore.

Please, welcome SimpleBaseBundle, a simple way of creating Bundles with one class for your RAD applications.

use Mmoreram\BaseBundle\Mapping\MappingBagProvider;
use Mmoreram\BaseBundle\SimpleBaseBundle;
use Symfony\Component\HttpKernel\KernelInterface;

/**
 * Class TestSimpleBundle
 */
class TestSimpleBundle extends SimpleBaseBundle
{
    /**
     * get config files
     */
    public function getConfigFiles() : array
    {
        return [
            'services'
        ];
    }

    /**
     * get mapping bag provider
     */
    public function getMappingBagProvider() : ? MappingBagProvider
    {
        return new class implements MappingBagProvider {

            /**
             * Get mapping bag collection.
             *
             * @return MappingBagCollection
             */
            public function getMappingBagCollection() : MappingBagCollection
            {
                return MappingBagCollection::create(
                    ['user' => 'User'],
                    '@TestSimpleBundle',
                    'Mmoreram\BaseBundle\Tests\Bundle\Entity'
                );
            }
        };
    }
    
    /**
     * Get command instance array
     *
     * @return Command[]
     */
    public function getCommands() : array
    {
        return [];
    }

    /**
     * Return a CompilerPass instance array.
     *
     * @return CompilerPassInterface[]
     */
    public function getCompilerPasses()
    {
        return [];
    }

    /**
     * Create instance of current bundle, and return dependent bundle namespaces.
     *
     * @return array Bundle instances
     */
    public static function getBundleDependencies(KernelInterface $kernel)
    {
        return [];
    }
}

and that's it.

With this class, you will create the bundle with its dependencies, you will initialize the commands and the Compiler Passes if needed, you will load the yaml config files and you will initialize the entities with the given configuration defined in the MappingBagProvider.

No need to create a DependencyInjection folder.

The method getMappingBagProvider can return null if you delegate to Doctrine on auto_mapping feature, and can return a real MappingBagProvider instance if you have the class in a file or an anonymous class as exposed in the example. If the mapping configuration defined that the mapping data is overwritable, then a MappingCompilerPass will be appended in your defined Compiler Passes, so there's no need to define it.

If your project takes another dimension or quality degree, then feel free to change your bundle implementation and start extending BaseBundle instead of SimpleBaseBundle. Then, create the needed DependencyInjection folder.

Extension

Another pain point each time you need to create a new Bundle. The bundle Extension is some kind of port between the bundle itself and all the dependency injection environment. You may be used to seeing files like this.

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;

/**
 * This is the class that loads and manages your bundle configuration
 */
class MyExtension extends Extension
{
    /**
     * Loads a specific configuration.
     *
     * @param array            $config    An array of configuration values
     * @param ContainerBuilder $container A ContainerBuilder instance
     *
     * @throws \InvalidArgumentException When provided tag is not defined in this extension
     *
     * @api
     */
    public function load(array $config, ContainerBuilder $container)
    {
        $configuration = new Configuration();
        $config = $this->processConfiguration($configuration, $config);

        /**
         * Setting all config elements as DI parameters to inject them
         */
        $container->setParameter(
            'my_parameter',
            $config['my_parameter']
        );

        $loader = new YamlFileLoader(
            $container,
            new FileLocator(__DIR__ . '/../Resources/config')
        );

        /**
         * Loading DI definitions
         */
        $loader->load('services.yml');
        $loader->load('commands.yml');
        $loader->load('controllers.yml');
    }
}

Extending BaseExtension

Difficult to remember, right? Well, that should never be a problem anymore. Take a look at this implementation using the BaseExtension.

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;

/**
 * This is the class that loads and manages your bundle configuration
 */
class MyExtension extends BaseExtension
{
    /**
     * Returns the recommended alias to use in XML.
     *
     * This alias is also the mandatory prefix to use when using YAML.
     *
     * @return string The alias
     *
     * @api
     */
    public function getAlias()
    {
        return 'app';
    }

    /**
     * Return a new Configuration instance.
     *
     * If object returned by this method is an instance of
     * ConfigurationInterface, extension will use the Configuration to read all
     * bundle config definitions.
     *
     * Also will call getParametrizationValues method to load some config values
     * to internal parameters.
     *
     * @return ConfigurationInterface Configuration file
     */
    protected function getConfigurationInstance()
    {
        return new Configuration();
    }

    /**
     * Get the Config file location.
     *
     * @return string Config file location
     */
    protected function getConfigFilesLocation()
    {
        return __DIR__ . '/../Resources/config';
    }

    /**
     * Config files to load.
     *
     * Each array position can be a simple file name if must be loaded always,
     * or an array, with the filename in the first position, and a boolean in
     * the second one.
     *
     * As a parameter, this method receives all loaded configuration, to allow
     * setting this boolean value from a configuration value.
     *
     * return array(
     *      'file1',
     *      'file2',
     *      ['file3', $config['my_boolean'],
     *      ...
     * );
     *
     * @param array $config Config definitions
     *
     * @return array Config files
     */
    protected function getConfigFiles(array $config)
    {
        return [
            'services',
            'commands',
            'controllers',
        ];
    }

    /**
     * Load Parametrization definition.
     *
     * return array(
     *      'parameter1' => $config['parameter1'],
     *      'parameter2' => $config['parameter2'],
     *      ...
     * );
     *
     * @param array $config Bundles config values
     *
     * @return array Parametrization values
     */
    protected function getParametrizationValues(array $config)
    {
        return [
            'my_parameter' => $config['my_parameter'],
        ];
    }
}

Maybe the file is larger, and you may notice that there are more lines of code, but seems to be easier to understand, right? This is what clean code means. There are only one thing this class will assume. Your services definitions use yml format. This is because is much more clear than XML and PHP, and because it's easier to interpret by humans. As you can see in the getConfigFiles method, you return the name of the file without the extension, being this always yml.

You can modify the container as well before and after the container is loaded by using these two methods.

//...

/**
 * Hook after pre-pending configuration.
 *
 * @param array            $config    Configuration
 * @param ContainerBuilder $container Container
 */
protected function preLoad(array $config, ContainerBuilder $container)
{
    // Implement here your bundle logic
}

/**
 * Hook after load the full container.
 *
 * @param array            $config    Configuration
 * @param ContainerBuilder $container Container
 */
protected function postLoad(array $config, ContainerBuilder $container)
{
    // Implement here your bundle logic
}

//...

Implementing EntitiesOverridableExtension

One of the coolest features this bundle can bring to your projects is the extremely easy way you can use Interfaces in your Doctrine declaration instead of specific implementations.

To understand a little bit more about this topic, take a look at this Symfony cookbook How to define Relationships with abstracts classes and interfaces.

This bundle allows you to define this relation between used interfaces or abstract classes and their specific implementation. The only thing you have to do is make your extension an implementation of the interface EntitiesOverridableExtension. Let's check an example.

use Mmoreram\BaseBundle\DependencyInjection\BaseExtension;
use Mmoreram\BaseBundle\DependencyInjection\EntitiesOverridableExtension;

/**
 * This is the class that loads and manages your bundle configuration
 */
class MyExtension extends BaseExtension implements EntitiesOverridableExtension
{
    // ...

    /**
     * Get entities overrides.
     *
     * Result must be an array with:
     * index: Original Interface
     * value: Parameter where class is defined.
     *
     * @return array Overrides definition
     */
    public function getEntitiesOverrides()
    {
        return [
            'My\Interface' => 'My\Entity'
        ];
    }
}

Configuration

The way your bundle will request and validate some data from the outside (app) is by using a configuration file. You can check the official Configuration Documentation if you want to know a little bit about this amazing feature.

Let's create a new configuration file for our bundle, and let's discover some nice features this library will provide you by extending the Configuration file.

use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Mmoreram\BaseBundle\DependencyInjection\BaseConfiguration;

/**
 * Class AppConfiguration.
 */
class AppConfiguration extends BaseConfiguration
{
    /**
     * {@inheritdoc}
     */
    protected function setupTree(ArrayNodeDefinition $rootNode)
    {
        $rootNode
            ->children()
                ->arrayNode('skills')
                    ->prototype('scalar')
                    ->end()
                ->end()
            ->end();
    }
}

Ops! What happens here? Lets check it out step by step.

Extension Alias

First of all, the configuration file will never define it's own name again. The configuration file should only define what kind of data should request to the app but not under what namespace.

So, who should define this namespace? The Extension should as is the one that really extends the dependency injection environment. In other words, even if for sure this will never be your scenario, you should be able to share a configuration file between different extensions.

So... how we can do that? If your configuration files extend this one, then, as long as you want to initialize it, you will have to define it's namespace. Take a look at this example. This method is part of your Extension file.

/**
 * Return a new Configuration instance.
 *
 * If object returned by this method is an instance of
 * ConfigurationInterface, extension will use the Configuration to read all
 * bundle config definitions.
 *
 * Also will call getParametrizationValues method to load some config values
 * to internal parameters.
 *
 * @return ConfigurationInterface Configuration file
 */
protected function getConfigurationInstance()
{
    return new Configuration(
        $this->getAlias()
    );
}

Extending BaseConfiguration

By extending the BaseConfiguration class you have this alias parameter in the constructor by default.

/**
 * Return a new Configuration instance.
 *
 * If object returned by this method is an instance of
 * ConfigurationInterface, extension will use the Configuration to read all
 * bundle config definitions.
 *
 * Also will call getParametrizationValues method to load some config values
 * to internal parameters.
 *
 * @return ConfigurationInterface Configuration file
 */
protected function getConfigurationInstance()
{
    return new BaseConfiguration(
        $this->getAlias()
    );
}

Using this class, you don't have to worry anymore about how to create the validation tree. Just define the validation tree under your extension defined alias.

/**
 * Configure the root node.
 *
 * @param ArrayNodeDefinition $rootNode Root node
 */
protected function setupTree(ArrayNodeDefinition $rootNode)
{
    $rootNode
        ->children()
            ...
}

By default, if you don't overwrite this method, no parametrization will be added under your bundle.

Compiler Pass

This library provides you some abstractions for your compiler passes to cover some specific use cases. Let's check them all.

In case you don't know what a Compiler Pass is yet, I encourage you to start with the documentation with How to work with compiler passes in bundles. The sooner you understand the importance of this class, the sooner you will have better bundles in your projects.

That simple.

Tag Compiler Pass

Imagine you want to get all service with an specific tag. Then you want to call another service's method with each one of these found services. This scenario is so common, and the Symfony documentation refers to it as How to Work with Service Tags.

Symfony allows you to do this, as the documentation says, in a very simple way, but this is not simple neither clear enough. Too many lines written once and again doing non-domain-related stuff.

Let's check the TagCompilerPass, an abstract class that will make this task as easy as implementing just 3 tiny methods.

use Mmoreram\BaseBundle\CompilerPass\FeederCompilerPass;

/**
 * Class FeederCompilerPass.
 */
final class FeederCompilerPass extends AbstractTagCompilerPass
{
    /**
     * Get collector service name.
     *
     * @return string Collector service name
     */
    public function getCollectorServiceName()
    {
        return 'my.collector.service';
    }

    /**
     * Get collector method name.
     *
     * @return string Collector method name
     */
    public function getCollectorMethodName()
    {
        return 'addClass';
    }

    /**
     * Get tag name.
     *
     * @return string Tag name
     */
    public function getTagName()
    {
        return 'my.tag';
    }
}

In this case, first of all we will check that a service with name my.collector.service exists. If exists, we will look for all services with tag my.tag and we will add them into this collector by using the collector method addClass.

Simple.

The Compiler Pass sorts as well the services before adding them all in the collector. To make this happen, you can add the priority in your tag line.

Provider

If you want to create this aliases of repositories and entity managers for your entities, even if you're not using any Mapping external library, you can do it by using these two provider services.

For using them, you should add, first of all, a reference of the providers.yml file in your application configuration.

imports:
    - { resource: '../../vendor/mmoreram/base-bundle/Resources/config/providers.yml' }

If BaseBundle is instanced in your kernel you can use the short bundle mode as well.

imports:
    - { resource: '@BaseBundle/Resources/config/providers.yml' }

ObjectManager Provider

Imagine that you're using Symfony and Doctrine in your project. You have an app, and for any reason you allowed DoctrineBundle to auto-discover all your entities by default. If you've created many connections and entity managers in your project, that example will fit your needs as well.

Let's think what happens in your dependency injection files.

services:
    cart_manager:
        class: AppBundle\CartManager
        arguments:
            - "@doctrine.orm.default_entity_manager"

Whats happening here? Well your service is now coupled to the entity manager assigned to manage your entity. If your application has only one single entity or one service, that should be OK, but what happens if your applications has many entities and many dependency injection files? What if your entity is no longer managed by the default entity manager, and now is being managed by another one called new_entity_manager?

Will you change all your yml files, one by one, looking for references to the right entity manager, changing them all? And what happens if a service is using the same entity manager ofr managing two entities, and one of them is not longer managed by it?

Think about it.

Well, one of the best options here is changing a little bit the way you think about the entity managers. Let's assume that each entity should have it's own entity manager, even if all of them are the same one.

Let's use the same entity example. We have an entity called cart, and is part of our bundle AppBundle. Our CartManager service is managing some Cart features and its entity manager is needed.

First step, creation of a new service pointing our Cart entity manager.

services:
    app.entity_manager.cart:
        parent: base.abstract_object_manager
        arguments:
            - App\Entity\Cart

After that, you will be able to use this new service in your other services. Let's go back to the last example.

services:
    cart_manager:
        class: AppBundle\CartManager
        arguments:
            - "@app.entity_manager.cart"

If you're using the default Symfony implementation, with the mapping auto-discover, the result of both implementations will be exactly the same, but in the future, if you decide to remove the mapping auto-discovering, or you split your applications in two different connections with several entity managers, you will only have to focus on your doctrine configuration. After that, your services will continue using the right entity manager.

As you could think, using this strategy means that you should never use the default entity manager again, and start using one entity manager per entity. So, what if your service is managing two entities at the same time? Easy, managing n entities means coupling to n entity managers, even if they are the same one. So please, make sure your services are small and do only what they have to do.

ObjectRepository Provider

Same for repositories. What if you want to inject your entity repository in your services? Well, you can do it by using the same strategy that you did in entity managers.

services:
    app.entity_repository.cart:
        parent: base.abstract_object_repository
        arguments:
            - App\Entity\Cart

After that, you'll be able to inject this new service in your domain.

services:
    cart_manager:
        class: AppBundle\CartManager
        arguments:
            - "@app.entity_repository.cart"

ObjectDirector

Some part of our application consists in a very small scope entity management. This means that we don't work wig big sets of data but with specific entities, like the scenario where we want to update a single entity.

  • We load if exists an entity (find)
  • If does'nt exist, we create a new one
  • We update this entity fields
  • We update the entity in our database (persist/flush)

For this scenario we should have in our service two different classes injected, the object manager for the data persistence actions, like persist or flush, and the object repository for our searches, but as you can see, most of these actions follow the same pattern, and use the same methods.

For this reason, we introduce the ObjectDirector class, a persistence simplification for these cases.

Only four methods are exposed to maintain this simplification

  • find
  • findOneBy
  • save
  • remove

and you can define the director as follows.

services:
    app.object_director.cart:
        class: Mmoreram\BaseBundle\ORM\ObjectDirector
        arguments:
            - "@app.entity_manager.cart"
            - "@app.entity_repository.cart"

This new service will simplify a little bit some persistence related logic inside your business classes, by reducing one dependency and the persistence scope.

Functional Tests

Some of the issues many projects have when they want to start testing their bundles in a functional way is that they don't really know how to handle with the kernel. The steps to follow are always the same.

  • Create a small bundle where to test your features
  • Create a kernel that works as a standalone application
  • Create a configuration for that kernel

But then some issues come as long as we want to test against several kernels and different kernel configurations.

How can we solve this?

Well, this is not going to be a problem anymore, at least with this library. Let's see a functional test and the way you can do it since this moment.

use Mmoreram\BaseBundle\Tests\BaseFunctionalTest;
use Mmoreram\BaseBundle\Tests\BaseKernel;

/**
 * Class TagCompilerPassTest.
 */
final class TagCompilerPassTest extends BaseFunctionalTest
{
    /**
     * Get kernel.
     *
     * @return KernelInterface
     */
    protected static function getKernel() : KernelInterface
    {
        return new BaseKernel(
            [
                'Mmoreram\BaseBundle\Tests\Bundle\TestBundle',
            ],
            [
                'services' => [
                    'my.service' => [
                        'class' => 'My\Class',
                        'arguments' => [
                            "a string",
                            "@another.service"
                        ]
                    ]
                ],
                'parameters' => [
                    'locale' => 'es'
                ],
                'framework' => [
                    'form' => true
                ]
            ],
            [
                ['/login', '@MyBundle:User:login', 'user_login'],
                ['/logout', '@MyBundle:User:logout', 'user_logout'],
            ]
        );
    }

    /**
     * Test compiler pass.
     */
    public function testCompilerPass()
    {
        // do your tests
    }
}

As you can see, you can do as many things as you need in order to create a unique scenario. With a simple class (your test) you can define all your app environment.

Let's see step by step what can you do here

BaseKernel

This library provides you a special kernel for your tests. This kernel is testing ready and allow you to customize as much as you need your application in each scenario. Each testing class will work with a unique kernel configuration, so all test cases inside this test class will be executed against this kernel.

This kernel uses the Symfony Bundle Dependencies project by default, so make sure you take a look at this project. Using it is not a must but a great option.

Let's see what do you need to create your own Kernel using the one this library offers to you.

new BaseKernel(
    [
        'Mmoreram\BaseBundle\Tests\Bundle\TestBundle',
    ],
    [
        'imports' => [
            ['resource' => '@BaseBundle/Resources/config/providers.yml'],
        ],
        'services' => [
            'my.service' => [
                'class' => 'My\Class',
                'arguments' => [
                    "a string",
                    "@another.service"
                ]
            ]
        ],
        'parameters' => [
            'locale' => 'es'
        ],
        'framework' => [
            'form' => true
        ]
    ],
    [
        ['/login', '@MyBundle:User:login', 'user_login'],
        ['/logout', '@MyBundle:User:logout', 'user_logout'],
        '@MyBundle/Resources/routing.yml',
    ]
);

Only three needed parameters for the kernel creation.

  • Array of bundle namespaces you need to instance the kernel. If you don't want to use the Symfony Bundle Dependencies project, make sure you add all of them. Otherwise, if you use the project, you should only add the bundle/s you want to test.

  • Configuration for the dependency injection component. Use the same format as you were using yml files but in PHP.

  • Routes. You can define single routes with an array of three positions. The first one is the path, the second one the Controller notation and the last one, the name of the route. You can define resources with the resource name.

In your configuration definition, and because of mostly all testing cases can be executed against FrameworkBundle and/or DoctrineBundle, you can preload a simple configuration per each bundle by adding these lines in your configuration array.

new BaseKernel(
    [
        'Mmoreram\BaseBundle\Tests\Bundle\TestBundle',
    ],
    [
        'imports' => [
            ['resource' => '@BaseBundle/Resources/config/providers.yml'],
            ['resource' => '@BaseBundle/Resources/test/framework.test.yml'],
            ['resource' => '@BaseBundle/Resources/test/doctrine.test.yml'],
        ],
        'services' => [
            'my.service' => [
                'class' => 'My\Class',
                'arguments' => [
                    "a string",
                    "@another.service"
                ]
            ]
        ],
    ],
    [
        ['/login', '@MyBundle:User:login', 'user_login'],
        ['/logout', '@MyBundle:User:logout', 'user_logout'],
        '@MyBundle/Resources/routing.yml',
    ]
);

Cache and logs

The question here would be... okay, but where can I find my Kernel cache and logs? Well, each kernel configuration (bundles, configuration and routing) is hashed in a unique string. Then, the system creates a folder under the /tmp/base-kernel folder and creates a unique kernel-{hash} folder inside.

Each time you reuse the same kernel configuration, this previous generated cache will be used in order to increase the performance of the tests.

To increase much more this performance, don't hesitate to create a tmpfs inside this /tmp/base-kernel folder by using this command.

sudo mount -t tmpfs -o size=512M tmpfs /tmp/base-kernel/

BaseFunctionalTest

As soon as you have the definition of how you should instance you kernel, we should create our first functional test. Let's take a look at how we can do that.

use Mmoreram\BaseBundle\Tests\BaseFunctionalTest;
use Mmoreram\BaseBundle\Tests\BaseKernel;

/**
 * Class TagCompilerPassTest.
 */
final class TagCompilerPassTest extends BaseFunctionalTest
{
    /**
     * Get kernel.
     *
     * @return KernelInterface
     */
    protected static function getKernel() : KernelInterface
    {
        return $kernel;
    }

    /**
     * Test compiler pass.
     */
    public function testCompilerPass()
    {
        // do your tests
    }
}

In every scenario your kernel will be created and saved locally. You can create your own kernel or use the BaseKernel, in both cases this will work properly, but take in account that this kernel will be active in the whole scenario.

Fast testing methods

Functional tests should test only application behaviors, so we should be able to reduce all this work that is not related to this one.

BaseFunctionalTest has a set of easy-to-use methods for use.

->get()

if you want to use any container service just call this method (like in controllers)

$this->assetInstanceOf(
    '\MyBundle\My\Service\Namespace',
    $this->get('service_name')
);

->has()

if you want to check if a container service exists, call this method. Useful for service existence testing

$this->assertTrue(
    $this->has('service_name')
);

->getParameter()

if you want to use any container parameter just call this method (like in controllers)

$this->assertEqual(
    'en',
    $this->getParameter('locale')
);

->getObjectRepository()

find in a fast way the object repository associated to an entity. You can define your entity namespace in several ways

  • MyBundle\Entity\Namespace\User
  • MyBundle:User
$this->assetInstanceOf(
    '\Doctrine\Common\Persistence\ObjectRepository',
    $this->getRepository('MyBundle:User')
);

->getObjectManager()

find in a fast way the object manager associated to an entity. You can define your entity namespace like in ->getObjectRepository() method

$this->assetInstanceOf(
    '\Doctrine\Common\Persistence\ObjectManager',
    $this->getManager('MyBundle:User')
);

->find()

find one entity instance by its namespace and id. You can define your entity namespace like in ->getObjectRepository() method

$this->assetInstanceOf(
    '\MyBundle\Entity\User',
    $this->find('MyBundle:User', 1)
);

->findOneBy()

find one entity instance complaining the passed criteria. You can define your entity namespace like in ->getObjectRepository() method

$this->assetInstanceOf(
    '\MyBundle\Entity\User',
    $this->findOneBy('MyBundle:User', [
        'name' => 'mmoreram',
    ])
);

->findAll()

get all entities given the namespace. You can define your entity namespace like in ->getObjectRepository() method

$this->assertCount(
    10,
    $this->findAll('MyBundle:User')
);

->findBy()

get all entities complaining the passed criteria. You can define your entity namespace like in ->getObjectRepository() method

$this->assertCount(
    3,
    $this->findBy('MyBundle:User', [
        'name' => 'mmoreram',
    ])
);

->clear()

Clear the entity manager associated to an entity. This means that you force doctrine to detach the entity type passed. You can define your entity namespace like in ->getObjectRepository() method

$this->clear('MyBundle:User');

This is useful when you save entities or changes from already existing entities and you want to test if the changes have really been applied. This flushes this cache.

->save()

Save any entity in an easy way. You can save an entity or an array of entities.

$this->save($user1);
$this->save([
    $user2,
    $user3,
]);

The method always persists, even if the entity is already attached to the object manager.

Working with Database

Of course, you may need to build the database schema in your tests, and because most of the cases your database creation are the same, you will be able to apply these steps just overwriting this method in your test.

/**
 * Schema must be loaded in all test cases.
 *
 * @return bool Load schema
 */
protected static function loadSchema()
{
    return true;
}

If you allow to load the schema, your database will be loaded at the beginning of your test case and will be dropped after it. By loaded we mean these steps

> bin/console doctrine:database:drop --force
> bin/console doctrine:database:create
> bin/console doctrine:schema:create

You can reload the schema during a test scenario as well, by invoking the ->reloadSchema() method. After this call, your schema will be clean (unless you use fixtures).

$this->reloadSchema();

You can debug your console output by overwriding the debug protected variable in your test case.

/**
 * @var bool
 *
 * Debug mode
 */
protected static $debug = true;

Working with Fixtures

The other need you may have in your functional tests is, after loading the database, load some fixtures. Because like the kernel, each fixtures configuration should be unique per each test case, you can define a set of fixtures in each test case overwriting a method, by default empty.

/**
 * Load fixtures of these bundles.
 *
 * @return array
 */
protected static function loadFixturePaths() : array
{
    return [
        '@MyBundle',
        '@MyOtherBundle,
    ];
}

By default, if you return an array of fixtures, the system will understand that you want to enable the database schema loading, so you don't need to overwrite the method loadSchema().

In this method you can add folders where to look for the fixtures, for example a bundle with short notation, or even a single file. To make sure you treat your fixtures properly, make sure you use the DependentFixtureInterface feature to define each fixture dependencies.

You can as well reset the fixtures in any part of your tests with the method reloadFixtures. This method will set the database as clean as before starting with the first current Test Case method.

$this->reloadFixtures();

This method will cause the same change in your database state than the method ->reloadSchema(), so apart of cleaning your schema and building it again, all fixtures will be loaded as well.

BaseFixture

As long as you need to create your Fixtures, this library provides you as well the same container accessors than provided in tests. Just make sure that your fixtures extend the BaseFixture class, and you'll be able to use all these methods as well.

/**
 * Class UserData.
 */
class UserData extends BaseFixture
{
    /**
     * Load data fixtures with the passed EntityManager.
     *
     * @param ObjectManager $manager
     */
    public function load(ObjectManager $manager)
    {
        $user1 = new User();
        $user1->setName('Joan');
        $this->save($user1);

        $this->find('my_prefix:user');
        $this->getObjectManager('my_prefix:user');

        // ...
    }
}

Even if you extend BaseFixture you can implement the same interfaces you've been using until now for dependent fixtures and ordered fixtures.

To make sure your fixtures are valid even if you decide in the future that your entity User is not managed anymore by the default Doctrine entity manager, use the ->save() method to persist and flush all entities. With these helpers should should never use the manager passed as parameter. If you need to get the whole object manager, use the ->getObjectManager() method.

Entity Mapping

Imagine this scenario.

You have a bundle with a model inside of it. By a model I mean a set of entity classes and a set of mapping files. You want to provide a simple way of defining this relation between the mapping files and the entity classes, and at the same, and by default, create some extra services and configuration layers to manage this configuration.

By default, Doctrine adds an auto-mapping layer with a not-very-good overwriting policy, so. let's see how can I disable this auto-mapping layer and start by using this super easy mapping layer.

Private bundles

In your private bundles, you may need only a soft layer of your model definition.

Let's introduce a simple interface called MappingBagProvider. If your bundle has a model, then your bundle has an instance of this class.

/**
 * Interface MappingBagProvider.
 */
interface MappingBagProvider
{
    /**
     * Get mapping bag collection.
     *
     * @return MappingBagCollection
     */
    public function getMappingBagCollection() : MappingBagCollection;
}

As you can see, one simple method and that will be enough. Let's see a simple implementation of this class for your bundle. For this scenario, imagine that we have this bundle configuration.

  • One entity called User under MyBundle\Entity namespace.
  • One mapping file under @MyBundle/Resources/config/doctrine folder called User.orm.yml

Our MappingBagProvider should be something like this

/**
 * Class MappingBagProvider.
 */
class MyBundleMappingBagProvider implements MappingBagProvider
{
    /**
     * Get mapping bag collection.
     *
     * @return MappingBagCollection
     */
    public function getMappingBagCollection() : MappingBagCollection
    {
        return MappingBagCollection::create(
            ['user' => 'User'],
            '@MyBundle',
            'MyBundle\Entity'
        );
    }
}

As you can see, this method returns a MappingBagCollection instance with some simple data.

  • An associative array of your entities
  • A bundle path where to look for the mapping files (you can use the short notation here)
  • The namespace where to find the entity classes

The second and last step to start working with your entities is the creation of a compiler pass in your bundle class. If you work with BaseBundle, then make use of the method defined for that.

final class MyBundle extends BaseBundle
{
    /**
     * Return a CompilerPass instance array.
     *
     * @return CompilerPassInterface[]
     */
    public function getCompilerPasses()
    {
        return [
            new MappingCompilerPass(new MyBundleMappingBagProvider()),
        ];
    }
}

and that's it, you model is already built with these amazing features.

  • Your entities are mapped with the YAML files inside the Resources path, created from the MappingBagCollection construct data. You should follow the Symfony standard by placing these mapping files inside the folder @MyBundle/Resources/config/doctrine with the standard name User.orm.yml. At the moment, only available for YAML files.
  • Per each entity mapped, the library has created these services.
    • object_manager.{entity_name} is an alias for the object manager assigned to this entity. You can inject it in your services. In that case you could use the service object_manager.user as an instance of Doctrine\Common\Persistence\ObjectManager
    • object_repository.{entity_name} is an alias for the object repository assigned to this entity. You can inject it as well in your services. In that case you could the service object_repository.user as an instance of Doctrine\Common\Persistence\ObjectRepository
    • object_director.{entity_name} is a new Director service assigned to this entity.
  • Per each entity mapped, you can find as well 4 parameters defined in your container, injectable as well in your services
    • entity.{entity_name}.class is the entity namespace used for the mapping. in that case, entity.user.class with a value of MyBundle\Entity\User.
    • entity.{entity_name}.mapping_file is the path of the mapping file used for the mapping of this class. in that case entity.user.mapping_file with a value of @MyBundle/Resources/config/doctrine/User.orm.yml
    • entity.{entity_name}.manager is the manager assigned to this entity, by default always default. In that case entity.user.manager with a value of default.
    • entity.{entity_name}.enabled is useful for next chapter, and has whether the entity is enabled or not. By default true. In that case entity.user.enabled with a value of true

Of course, many of these things can be configured by adding more parameters in our MappingBagProvider implementation.

/**
 * Class MappingBagProvider.
 */
class MyBundleMappingBagProvider implements MappingBagProvider
{
    /**
     * Get mapping bag collection.
     *
     * @return MappingBagCollection
     */
    public function getMappingBagCollection() : MappingBagCollection
    {
        return MappingBagCollection::create(
            ['user' => 'User'],
            '@MyBundle',
            'MyBundle\Entity',
            'my_prefix',
            'another_manager',
            'manager',
            'repository',
            false
        );
    }
}

Let's explain each of these extra parameters

  • 'my_prefix' will be used when defining container entries (services and parameters), so with a value of my_prefix, we will have these values instead of the ones defined below
    • my_prefix.object_manager.user
    • my_prefix.object_repository.user
    • my_prefix.entity.user.class
    • my_prefix.entity.user.mapping_file
    • my_prefix.entity.user.manager
    • my_prefix.entity.user.enabled
  • another_manager will be used as the default object manager in all defined entities. With the value another_manager the value of the parameter my_prefix.entity.user.mapping_file would be another_manager' instead of default`. Take in account that the object_manager defined here must be defined as well under the Doctrine ORM configuration
  • manager will be used as the name of the generated object manager aliases. With this value, we would generate a service called my_prefix.manager.user instead of my_prefix.object_manager.user
  • repository will be used as the name of the generated object repository aliases. With this value, we would generate a service called my_prefix.repository.user instead of my_prefix.object_manager.user
  • the last method optional boolean parameter, false, is the way you have to enable the mapping external configuration. We will see this feature in next chapters. By default, always false.

Public bundles

So, what if you want to expose you bundles for everyone? And what if you want to enable other user to overwrite the entity class, the mapping file, the object manager assigned to this entity or even disable the entity?

Do it in 3 simple steps.

First step, define that you want to enable this feature in the last MappingBagProvider parameter.

/**
 * Class MappingBagProvider.
 */
class MyBundleMappingBagProvider implements MappingBagProvider
{
    /**
     * Get mapping bag collection.
     *
     * @return MappingBagCollection
     */
    public function getMappingBagCollection() : MappingBagCollection
    {
        return MappingBagCollection::create(
            ['user' => 'User'],
            '@MyBundle',
            'MyBundle\Entity',
            'my_prefix',
            'another_manager',
            'manager',
            'repository',
            true // Change this value from false to true
        );
    }
}

In that case, make sure all other values are properly defined.

Second step, pass the MappingBagProvider instance to your Configuration in your bundle class.

/**
 * Class TestMappingBundle.
 */
final class TestMappingBundle extends BaseBundle
{
    /**
     * Return a CompilerPass instance array.
     *
     * @return CompilerPassInterface[]
     */
    public function getCompilerPasses()
    {
        return [
            new MappingCompilerPass(new MyBundleMappingBagProvider()),
        ];
    }

    /**
     * Returns the bundle's container extension.
     *
     * @return ExtensionInterface|null The container extension
     *
     * @throws \LogicException
     */
    public function getContainerExtension()
    {
        return new TestMappingExtension(new MyBundleMappingBagProvider());
    }
}

Last step, in your Extension, if you don't have any configuration defined yet, you can use a BaseConfiguration instance, so you don't need to create any extra class. Important! Pass the MappingBagProvider saved locally as the second parameter.

/**
 * Class TestMappingExtension.
 */
class MyBundleExtension extends BaseExtension
{
    /**
     * Return a new Configuration instance.
     *
     * If object returned by this method is an instance of
     * ConfigurationInterface, extension will use the Configuration to read all
     * bundle config definitions.
     *
     * Also will call getParametrizationValues method to load some config values
     * to internal parameters.
     *
     * @return ConfigurationInterface|null
     */
    protected function getConfigurationInstance() : ? ConfigurationInterface
    {
        return new BaseConfiguration(
            $this->getAlias(),
            $this->mappingBagProvider
        );
    }
}

If you have your Extension created already, just pass the MappingBagProvider as the second parameter. No extra classes to create, just one line.

/**
 * Class TestMappingExtension.
 */
class MyBundleExtension extends BaseExtension
{
    /**
     * Return a new Configuration instance.
     *
     * If object returned by this method is an instance of
     * ConfigurationInterface, extension will use the Configuration to read all
     * bundle config definitions.
     *
     * Also will call getParametrizationValues method to load some config values
     * to internal parameters.
     *
     * @return ConfigurationInterface|null
     */
    protected function getConfigurationInstance() : ? ConfigurationInterface
    {
        return new MyBundleConfiguration(
            $this->getAlias(),
            $this->mappingBagProvider
        );
    }
}

And that's it! Now you can overwrite all your mapping values from the config application.

{extension_alias}:
    mapping:
        user:
            class: "AnotherBundle\Entity\AnotherUser"
            mapping_file: "@AnotherBundle/Resources/config/doctrine/AnotherUser.orm.yml"
            manager: "another_manager"
            enabled: false

The extension_alias value will always depend on the Extension alias, and in that case, your mapping_file value can point to a YML or XML mapping file.

Bundles and Components

This library is useful as well when you want to change your bundle and split it between the Symfony Bundle and the PHP Component.

After this split, make sure that your MappingBagProvider defines the new mapping namespace properly

/**
 * Class MappingBagProvider.
 */
class MyBundleMappingBagProvider implements MappingBagProvider
{
    /**
     * Get mapping bag collection.
     *
     * @return MappingBagCollection
     */
    public function getMappingBagCollection() : MappingBagCollection
    {
        return MappingBagCollection::create(
            ['user' => 'User'],
            '@MyBundle',
            'MyOtherNamespace\Entity'
        );
    }
}

Exposing your mapping without BaseBundle

You can expose your mapping without using BaseExtension and BaseConfiguration. What these two classes do for you is just adding all the fields in your configuration tree, validating them all and adding the resulting values in your container as parameters, so if you want to do these steps without BaseBundle make sure that, at the end, you have the right parameters in your container.

Documentation extra

Some libraries will be used as well during the documentation. We encourage you to check them all in order to increase the quality of your bundles and the way you know them.