phpsymfonysymfony4symfony-flex

More than one application per project repository with Symfony 4


I have three old applications (running on Symfony 2) where each one has been developed in separated git repositories and configured in their respective vhosts:

  1. company.com Company website.
  2. admin.company.com Website administration.
  3. api.company.com API company service.

Even though, they share the same database. So we're decided (the Company) unify all of them in one application with Symfony 4 structure & approach, mainly to remove a big quantity of duplicated data and to improve its maintenance.

Right now, I'm integrating all in one application/repository as was planned, but I'm starting to deal with some performance & structure issues:

I'd like to keep the early vhost and load just the needed bundles and configuration per domains:

  1. company.com Loads bundles, routes and configuration for a company website only (SwiftmailerBundle, ...)
  2. admin.company.com Loads bundles, routes and configuration only for website administration (SecurityBundle, SonataAdminBundle, ...)
  3. api.company.com Loads just the bundles, routes and configuration to provide a fast API company service (SecurityBundle, FOSRestBundle, NelmioApiDocBundle, ...)

This is what I'm doing so far:

// public/index.php

// ...

$request = Request::createFromGlobals();
$kernel = new Kernel(getenv('APP_ENV'), getenv('APP_DEBUG'));

// new method implemented in my src/kernel.php
$kernel->setHost($request->server->get('HTTP_HOST'));

$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

I've check the current host prefix in Kernel::registerBundles() method and I loaded the needed bundles only, but still I've problems with bin/console file (it doesn't work as HTTP_HOST variable is not defined for CLI) I'd like to clear the cache for each "sub-app" and so on.

I have been doing some research on this topic but so far I couldn't find anything helpful for my scenario (Symfony 4).

Is possible to have many applications under one project repository running independently (like individual apps) but sharing some configuration? What is the best approach to achieve it?

Thanks in advance.


Solution

  • Likely the multiple kernels approach could be a good option to solve this kind of project, but thinking now in Symfony 4 approach with environment variables, structure and kernel implementation, it could be improved.

    Name-based Virtual Kernel

    The term "Virtual Kernel" refers to the practice of running more than one application (such as api.example.com and admin.example.com) on a single project repository. Virtual kernels are "name-based", meaning that you have multiple kernel names running on each application. The fact that they are running on the same physical project repository is not apparent to the end user.

    In short, each kernel name corresponds to one application.

    Application-based Configuration

    First, you'll need replicate the structure of one application for config, src, var directories and leave the root structure for shared bundles and configuration. It should look like this:

    ├── config/
    │   ├── admin/
    │   │   ├── packages/
    │   │   ├── bundles.php
    │   │   ├── routes.yaml
    │   │   ├── security.yaml
    │   │   └── services.yaml
    │   ├── api/
    │   ├── site/
    │   ├── packages/
    │   ├── bundles.php
    ├── src/
    │   ├── Admin/
    │   ├── Api/
    │   ├── Site/
    │   └── VirtualKernel.php
    ├── var/
    │   ├── cache/
    │   │   ├── admin/
    │   │   │   └── dev/
    │   │   │   └── prod/
    │   │   ├── api/
    │   │   └── site/
    │   └── log/
    

    Next, making use of the Kernel::$name property you can stand out the application to run with dedicated project files (var/cache/<name>/<env>/*):

    This will be the key of the performance as each application has by definition its own DI container, routes and configuration files. Here is a complete sample of the VirtualKernel class that supports the previous structure:

    src/VirtualKernel.php

    // WITHOUT NAMESPACE!
    
    use Symfony\Component\HttpKernel\Kernel;
    
    class VirtualKernel extends Kernel
    {
        use MicroKernelTrait;
    
        private const CONFIG_EXTS = '.{php,xml,yaml,yml}';
    
        public function __construct($environment, $debug, $name)
        {
            $this->name = $name;
    
            parent::__construct($environment, $debug);
        }
    
        public function getCacheDir(): string
        {
            return $this->getProjectDir().'/var/cache/'.$this->name.'/'.$this->environment;
        }
    
        public function getLogDir(): string
        {
            return $this->getProjectDir().'/var/log/'.$this->name;
        }
    
        public function serialize()
        {
            return serialize(array($this->environment, $this->debug, $this->name));
        }
    
        public function unserialize($data)
        {
            [$environment, $debug, $name] = unserialize($data, array('allowed_classes' => false));
    
            $this->__construct($environment, $debug, $name);
        }
    
        public function registerBundles(): iterable
        {
            $commonBundles = require $this->getProjectDir().'/config/bundles.php';
            $kernelBundles = require $this->getProjectDir().'/config/'.$this->name.'/bundles.php';
    
            foreach (array_merge($commonBundles, $kernelBundles) as $class => $envs) {
                if (isset($envs['all']) || isset($envs[$this->environment])) {
                    yield new $class();
                }
            }
        }
    
        protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void
        {
            $container->setParameter('container.dumper.inline_class_loader', true);
    
            $this->doConfigureContainer($container, $loader);
            $this->doConfigureContainer($container, $loader, $this->name);
        }
    
        protected function configureRoutes(RouteCollectionBuilder $routes): void
        {
            $this->doConfigureRoutes($routes);
            $this->doConfigureRoutes($routes, $this->name);
        }
    
        private function doConfigureContainer(ContainerBuilder $container, LoaderInterface $loader, string $name = null): void
        {
            $confDir = $this->getProjectDir().'/config/'.$name;
            if (is_dir($confDir.'/packages/')) {
                $loader->load($confDir.'/packages/*'.self::CONFIG_EXTS, 'glob');
            }
            if (is_dir($confDir.'/packages/'.$this->environment)) {
                $loader->load($confDir.'/packages/'.$this->environment.'/**/*'.self::CONFIG_EXTS, 'glob');
            }
            $loader->load($confDir.'/services'.self::CONFIG_EXTS, 'glob');
            if (is_dir($confDir.'/'.$this->environment)) {
                $loader->load($confDir.'/'.$this->environment.'/**/*'.self::CONFIG_EXTS, 'glob');
            }
        }
    
        private function doConfigureRoutes(RouteCollectionBuilder $routes, string $name = null): void
        {
            $confDir = $this->getProjectDir().'/config/'.$name;
            if (is_dir($confDir.'/routes/')) {
                $routes->import($confDir.'/routes/*'.self::CONFIG_EXTS, '/', 'glob');
            }
            if (is_dir($confDir.'/routes/'.$this->environment)) {
                $routes->import($confDir.'/routes/'.$this->environment.'/**/*'.self::CONFIG_EXTS, '/', 'glob');
            }
            $routes->import($confDir.'/routes'.self::CONFIG_EXTS, '/', 'glob');
        }
    }
    

    Now your \VirtualKernel class requires an extra argument (name) that defines the application to load. In order for the autoloader to find your new \VirtualKernel class, make sure add it to composer.json autoload section:

    "autoload": {
        "classmap": [
            "src/VirtualKernel.php"
        ],
        "psr-4": {
            "Admin\\": "src/Admin/",
            "Api\\": "src/Api/",
            "Site\\": "src/Site/"
        }
    },
    

    Then, run composer dump-autoload to dump the new autoload config.

    Keeping one entry point for all applications

    ├── public/
    │   └── index.php
    

    Following the same filosofy of Symfony 4, whereas environment variables decides which development environment and debug mode should be used to run your application, you could add a new APP_NAME environment variable to set the application to execute:

    public/index.php

    // ...
    
    $kernel = new \VirtualKernel(getenv('APP_ENV'), getenv('APP_DEBUG'), getenv('APP_NAME'));
    // ...
    

    For now, you can play with it by using PHP's built-in Web server, prefixing the new application environment variable:

    $ APP_NAME=site php -S 127.0.0.1:8000 -t public
    $ APP_NAME=admin php -S 127.0.0.1:8001 -t public
    $ APP_NAME=api php -S 127.0.0.1:8002 -t public    
    

    Executing commands per application

    ├── bin/
    │   └── console.php
    

    Add a new console option --kernel to be able to run commands from different applications:

    bin/console

    // ...
    $name = $input->getParameterOption(['--kernel', '-k'], getenv('APP_NAME') ?: 'site');
    
    //...
    $kernel = new \VirtualKernel($env, $debug, $name);
    $application = new Application($kernel);
    $application
        ->getDefinition()
        ->addOption(new InputOption('--kernel', '-k', InputOption::VALUE_REQUIRED, 'The kernel name', $kernel->getName()))
    ;
    $application->run($input);
    

    Later, use this option to run any command different to default one (site).

    $ bin/console about -k=api
    

    Or if you prefer, use environment variables:

    $ export APP_NAME=api
    $ bin/console about                         # api application
    $ bin/console debug:router                  # api application
    $
    $ APP_NAME=admin bin/console debug:router   # admin application
    

    Also you can configure the default APP_NAME environment variable in the .env file.

    Running tests per application

    ├── tests/
    │   ├── Admin/
    │   │   └── AdminWebTestCase.php
    │   ├── Api/
    │   ├── Site/
    

    The tests directory is pretty similar to the src directory, just update the composer.json to map each directory tests/<Name>/ with its PSR-4 namespace:

    "autoload-dev": {
        "psr-4": {
            "Admin\\Tests\\": "tests/Admin/",
            "Api\\Tests\\": "tests/Api/",
            "Site\\Tests\\": "tests/Site/"
        }
    },
    

    Again, run composer dump-autoload to re-generate the autoload config.

    Here, you might need create a <Name>WebTestCase class per application in order to execute all tests together:

    test/Admin/AdminWebTestCase

    namespace Admin\Tests;
    
    use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
    
    abstract class AdminWebTestCase extends WebTestCase
    {
        protected static function createKernel(array $options = array())
        {
            return new \VirtualKernel(
                isset($options['environment']) ? $options['environment'] : 'test',
                isset($options['debug']) ? $options['debug'] : true,
                'admin'
            );
        }
    }
    

    Later, extends from AdminWebTestCase to test admin.company.com application (Do the same for another ones).

    Production and vhosts

    Set the environment variable APP_NAME for each vhost config in your production server and development machine:

    <VirtualHost company.com:80>       
        SetEnv APP_NAME site
    
        # ...
    </VirtualHost>
    
    <VirtualHost admin.company.com:80>        
        SetEnv APP_NAME admin
    
        # ...
    </VirtualHost>
    
    <VirtualHost api.company.com:80>
        SetEnv APP_NAME api
    
        # ...
    </VirtualHost>
    

    Adding more applications to the project

    With three simple steps you should be able to add new vKernel/applications to the current project:

    1. Add to config, src and tests directories a new folder with the <name> of the application and its content.
    2. Add to config/<name>/ dir at least the bundles.php file.
    3. Add to composer.json autoload/autoload-dev sections the new PSR-4 namespaces for src/<Name>/ and tests/<Name> directories and update the autoload config file.

    Check the new application running bin/console about -k=<name>.

    Final directory structure:

    ├── bin/
    │   └── console.php
    ├── config/
    │   ├── admin/
    │   │   ├── packages/
    │   │   ├── bundles.php
    │   │   ├── routes.yaml
    │   │   ├── security.yaml
    │   │   └── services.yaml
    │   ├── api/
    │   ├── site/
    │   ├── packages/
    │   ├── bundles.php
    ├── public/
    │   └── index.php
    ├── src/
    │   ├── Admin/
    │   ├── Api/
    │   ├── Site/
    │   └── VirtualKernel.php
    ├── tests/
    │   ├── Admin/
    │   │   └── AdminWebTestCase.php
    │   ├── Api/
    │   ├── Site/
    ├── var/
    │   ├── cache/
    │   │   ├── admin/
    │   │   │   └── dev/
    │   │   │   └── prod/
    │   │   ├── api/
    │   │   └── site/
    │   └── log/
    ├── .env
    ├── composer.json
    

    Unlike multiple kernel files approach, this version reduces a lot of code duplication and files; just one kernel, index.php and console for all applications, thanks to environment variables and virtual kernel class.

    Example based-on Symfony 4 skeleton: https://github.com/yceruto/symfony-skeleton-vkernel Inspired in https://symfony.com/doc/current/configuration/multiple_kernels.html