Migrating progressively to Symfony without pain

Atrápalo is a travel e-commerce website founded in 2000. Based in Barcelona, Spain, it sells flights, trips, tickets, booking restaurants, car renting, etc. to 10 different countries. It’s a 9000 world Alexa ranking and it’s running PHP. Since 2014, we are pushing hard in order to evolve technically using best practices, agile methodologies and distributed architectures. One of the key aspects is the framework.

We are currently migrating to Symfony in order to speed up the development process and reduce the maintenance costs. We are doing it progressively, step by step, without rewriting the whole application, no green-field project, without any dedicated team neither. All developers are involved in this process, and by policy, each new feature is developed using Symfony while the old features remain served by the old framework.

I would say this process is going quite smoothly, without pain. Based on some emails and tweets I have received, here are some tricks about how we are doing it. Hope it helps!

tl;dr

In our app, Symfony and our  current framework live together. Some requests are served by the old framework and the new features are served by Symfony, by department policy. An easy approach to do that without pain is to use the Apache dumper feature of the Symfony Router component. When deploying, an Apache config file is generated that sends all the Symfony @Route to the app_*.php file and the rest to the index.php of old framework. Done!

Entry points

Let’s assume you have your own custom framework. We have one of those. It has a single entry point, an index.php file that manages all the incoming requests. An example of an Apache configuration file or .htaccess would be like:

<IfModule mod_rewrite.c>
    Options -MultiViews

    RewriteEngine On
    # Here some of your custom rewrites...
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^ index.php [QSA,L]
</IfModule>

Imagine for a sec that we could add some rules that would send to the Symfony app_*.php some request if they match your @Route regexp. For example, the routes related to the profiler:

<IfModule mod_rewrite.c>
    Options -MultiViews

    RewriteEngine On
    
    # _profiler_home
    RewriteCond %{REQUEST_URI} ^/_profiler$
    RewriteRule ^ $0/ [QSA,L,R=301]
    RewriteCond %{REQUEST_URI} ^/_profiler/$
    RewriteRule ^ app_dev.php [QSA,L]

    # _profiler_search
    RewriteCond %{REQUEST_URI} ^/_profiler/search$
    RewriteRule ^ app_dev.php [QSA,L]
    # ... more rules

    # Here some of your custom rewrites...
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^ index.php [QSA,L]
</IfModule>

With this approach is really easy to dispatch requests to one framework or the other, but, how can I generate these rules?

Generating the Symfony routes

With the Apache dumper feature of the Symfony Route Component. This feature is deprecated but you should be able to copy&paste the command and tune it for your goal if you need it.

If you don’t want to use this command, you can write your own, but it’s going to be a bit tricky.

There is no nginx dumper, but it shouldn’t be difficult to write one.

The existing command was thought to improve the performance of the Symfony apps running on Apache. Now, with PHP and Symfony improvements, it’s not so necessary this approach, but interesting for the migration one.

Other details

We are using Hexagonal Architecture, sharing the same Symfony Container, so reusing the Business logic between frameworks is easy for us. Using this technique is almost mandatory in a step by step migration process. Each time we deploy, we generate the Apache file.

Alternatives

I have seen people trying to integrate Symfony routing with its own routing and failing. It could work, but it’s quite work. With the approach explained here, it’s just a matter of your webserver. Another option, should be Symfony making curl requests to the old framework. The last one seen, is using both frameworks as a middleware of StackPHP. We have tried the last one but no success.

Show me some code

Just as an example. First of all the “copy&paste” of the router:dump of Symfony with some tweaks. You have the original version here. It uses another dumper and customises the path to generate the configuration file.

namespace Atrapalo\Bundle\CoreBundle\Command;

use Atrapalo\Bundle\CoreBundle\Routing\Matcher\Dumper\DirectMatcherDumper;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Routing\RouterInterface;

class DumpMigratedRoutesCommand extends ContainerAwareCommand
{
    /**
     * {@inheritdoc}
     */
    public function isEnabled()
    {
        if (!$this->getContainer()->has('router')) {
            return false;
        }
        $router = $this->getContainer()->get('router');
        if (!$router instanceof RouterInterface) {
            return false;
        }

        return parent::isEnabled();
    }

    /**
     * {@inheritdoc}
     */
    protected function configure()
    {
        $this
            ->setName('router:dump:migrated')
            ->setDefinition([
                new InputArgument(
                    'script-name',
                    InputArgument::OPTIONAL,
                    'The script name of the application\'s front controller.'
                ),
                new InputArgument(
                    'output-file',
                    InputArgument::OPTIONAL,
                    'The path to the output file where the routes will be dumped.',
                    getcwd() . '/resources/routes.conf'
                ),
                new InputOption('base-uri', null, InputOption::VALUE_REQUIRED, 'The base URI'),
            ])
            ->setDescription('Dumps all migrated routes as Apache rewrite rules')
            ->setHelp(
                <<<EOF
The <info>%command.name%</info> dumps all routes as Apache rewrite rules.

  <info>php %command.full_name%</info>

EOF
            )
        ;
    }

    /**
     * {@inheritdoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $router = $this->getContainer()->get('router');

        $dumpOptions = [];

        if ($input->getArgument('script-name')) {
            $dumpOptions['script_name'] = $input->getArgument('script-name');
        }

        if ($input->getOption('base-uri')) {
            $dumpOptions['base_uri'] = $input->getOption('base-uri');
        }

        $outputFilePath = $input->getArgument('output-file');

        if (!is_writable(dirname($outputFilePath))) {
            throw new InvalidArgumentException(
                sprintf('The path "%s" is not writable!', dirname($outputFilePath))
            );
        }

        $dumper = new DirectMatcherDumper(
            $input->getParameterOption(['--env', '-e'], 'dev'),
            $router->getRouteCollection()
        );

        $output->writeln(
            sprintf('<info>Dumping routes to <comment>%s</comment>', $outputFilePath)
        );

        file_put_contents(
            $outputFilePath,
            $dumper->dump($dumpOptions)
        );
    }
}

And now the dumper that generates the configuration file with all the routes.

namespace Atrapalo\Bundle\CoreBundle\Routing\Matcher\Dumper;

use LogicException;
use Symfony\Component\Routing\CompiledRoute;
use Symfony\Component\Routing\Matcher\Dumper\MatcherDumper;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

class DirectMatcherDumper extends MatcherDumper
{
    public function __construct($env, RouteCollection $routes)
    {
        $this->env = $env;

        parent::__construct($routes);
    }

    /**
     * Dumps a set of routes to a string representation of executable code
     * that can then be used to match a request against these routes.
     *
     * @param array $options An array of options
     *
     * @return string Executable code
     */
    public function dump(array $options = [])
    {
        if ($this->getRoutes()->count() == 0) {
            return '';
        }

        $options = array_merge([
            'script_name' => '<your-default-path>/app_dev.php',
            'base_uri'    => '',
        ], $options);

        $options['script_name'] = self::escape($options['script_name'], ' ', '\\');

        $rules = [];
        $processedRoutes = [];

        foreach ($this->getRoutes()->all() as $name => $route) {
            if ($this->isAlreadyDumped($route, $processedRoutes)) {
                continue;
            }

            if ($route->hasOption('exclude-env')) {
                $excludedEnvironments = array_map('trim', explode(',', $route->getOption('exclude-env')));

                if (in_array($this->env, $excludedEnvironments)) {
                    continue;
                }
            }

            if ($route->getCondition()) {
                throw new LogicException(
                    sprintf('Unable to dump the routes for Apache as route "%s" has a condition.', $name)
                );
            }

            $rules[] = $this->dumpRoute($name, $route, $options, $processedRoutes);
            $processedRoutes[] = $route->getPath();
        }

        return implode("\n\n", $rules)."\n";
    }

    /**
     * Dumps a single route
     *
     * @param string $name    Route name
     * @param Route  $route   The route
     * @param array  $options Options
     *
     * @return string The compiled route
     */
    private function dumpRoute($name, $route, array $options, array &$processedRoutes)
    {
        $compiledRoute = $route->compile();

        // prepare the apache regex
        $regex = $this->routeRegexp($compiledRoute);
        $regex = '^' . self::escape(preg_quote($options['base_uri']) . substr($regex, 1), ' ', '\\');

        $hasTrailingSlash = '/$' === substr($regex, -2) && '^/$' !== $regex;

        $rule = ["# $name"];

        $hostRegex = $compiledRoute->getHostRegex();

        // redirect with trailing slash appended
        if ($hasTrailingSlash) {

            if (null !== $hostRegex) {
                $rule[] = $this->buildHostRewriteCond($hostRegex);
            }

            $rule[] = 'RewriteCond %{REQUEST_URI} '.substr($regex, 0, -2).'$';
            $rule[] = 'RewriteRule .* $0/ [QSA,L,R=301]';
        }

        if (null !== $hostRegex) {
            $rule[] = $this->buildHostRewriteCond($hostRegex);
        }

        // the main rule
        $rule[] = "RewriteCond %{REQUEST_URI} $regex";
        $rule[] = "RewriteRule .* {$options['script_name']} [QSA,L]";

        $processedRoutes[$hostRegex][] = $regex;

        return implode("\n", $rule);
    }

    /**
     * Converts a regex to make it suitable for mod_rewrite
     *
     * @param string $regex The regex
     *
     * @return string The converted regex
     */
    private function regexToApacheRegex($regex)
    {
        $regexPatternEnd = strrpos($regex, $regex[0]);

        return preg_replace('/\?P<.+?>/', '', substr($regex, 1, $regexPatternEnd - 1));
    }

    /**
     * Escapes a string.
     *
     * @param string $string The string to be escaped
     * @param string $char   The character to be escaped
     * @param string $with   The character to be used for escaping
     *
     * @return string The escaped string
     */
    private static function escape($string, $char, $with)
    {
        $escaped = false;
        $output = '';
        foreach (str_split($string) as $symbol) {
            if ($escaped) {
                $output .= $symbol;
                $escaped = false;
                continue;
            }
            if ($symbol === $char) {
                $output .= $with.$char;
                continue;
            }
            if ($symbol === $with) {
                $escaped = true;
            }
            $output .= $symbol;
        }

        return $output;
    }

    private function buildHostRewriteCond($hostRegex)
    {
        $apacheHostRegex = $this->regexToApacheRegex($hostRegex);
        $apacheHostRegex = self::escape($apacheHostRegex, ' ', '\\');

        return sprintf('RewriteCond %%{HTTP_HOST} %s', $apacheHostRegex);
    }

    public function isAlreadyDumped(Route $route, array &$processedPaths)
    {
        $compiledRoute = $route->compile();

        $hostRegexp = $compiledRoute->getHostRegex();

        if (!isset($processedPaths[$hostRegexp])) {
            $processedPaths[$hostRegexp] = [];

            return false;
        }

        return in_array($this->routeRegexp($compiledRoute), $processedPaths[$hostRegexp], true);
    }

    private function routeRegexp(CompiledRoute $compiledRoute)
    {
        return $this->regexToApacheRegex($compiledRoute->getRegex());
    }
}
  • Really interesting, thx for sharing ;). Just a question, how do you manage dbs? I guess that symfony at least should access to the former fw database, right?

    Thanks in advance, Simon.

    • Carlos Buenosvinos

      You mean same connection? Each request is served by a framework, the old or Symfony, but not mixed. In order not to duplicate configurations we have integrated the Symfony Service Container in our current framework. It’s quite easy. Services to connect to the database, or the Doctrine Entity manager are in the SC. Does this answer your question?

    • cbuenosvinos

      You can share the Service Container between the new and the old framework.

  • carlescliment

    Hi Carlos, thanks a lot for this post. How do you handle user sessions?

  • Pingback: Migrating progressively to Symfony without pain with StackPHP | Carlos Buenosvinos()