Migration Symfony 1.4 to Symfony 4.4 using Strangler Pattern

In the previous article, I explained what a legacy system is and how you can migrate it to the modern one using Strangler Pattern. In this article, I’ll show you the use case of migration legacy system to the modern one based on a Symfony framework.

Assume you have an application built using Symfony 1.4 framework. It’s still in active development but uses a legacy PHP 5.* version which is no longer supported. It’s still maintained but was implemented several years ago. As we know what the current situation is we can go through requirements.

Requirements: 

  • The system should be running on the newer, continuously supported PHP version. 
  • Both legacy and the modern systems should be transparent for the users, so the UI is supposed to be the same. 
  • A modern system should be implemented using the Symfony 4+ framework version. 
  • A client wants to have Symfony 4 web profiler available for both legacy and modern application. 

Now you know the current situation and what the requirements are. It means that you are ready to start migrating the system.

Choose a migration strategy 

First, you need to choose the migration strategy. As you could read in the previous article there are several different ways to do this. In this case, the best approach is Legacy Route Loader. As a PHP is a scripting language there is no problem running external scripts in the other one. Thanks to that we can check if a requested route exists in one app and if it doesn’t, we can run the legacy script instead.

Investigate how the legacy system works 

We’ve chosen a migration strategy. Now we need to look at the Symfony 1.4 and get to know how it actually works. This is an old framework and there’s no general entry point. It consists of several separate modules called applications. Every application has its own front controller. Each one gets the request, then runs some business logic and finally returns a response as an output.

<?php 
require_once(dirname(__FILE__).'/../config/ProjectConfiguration.class.php'); 

$configuration = ProjectConfiguration::getApplicationConfiguration('frontend', 'prod', false); 
sfContext::createInstance($configuration)->dispatch(); 

But it’s not all you need to do. There’s a problem with the legacy front controller. Symfony 1.4 writes a response to the output. To capture this response, we have to capture this output. PHP comes with output buffering that you can use. If you don’t buffer this response, you won’t be able to catch one and pass to Symfony 4 response object. In the worst case, it can end up with a double response from the server. The double response will display two copies of the same page on the browser screen.

Thanks to an output buffering you are able to store responses in an internal buffer. Then you can get buffered content and pass it to the response variable which is a part of sfContext object in Symfony 1.4.

<?php 
require_once(dirname(__FILE__).'/../config/ProjectConfiguration.class.php'); 

$configuration = ProjectConfiguration::getApplicationConfiguration('frontend', $_SERVER['APP_ENV'], false); 

$sfContext = sfContext::createInstance($configuration); 
ob_start(); 
$sfContext->dispatch(); 
$sfContext->getResponse()->setContent(ob_get_contents()); 
$sfContext->shutdown(); 
ob_clean(); 

Implement Route Loader 

Now, you have some more knowledge of Symfony 1.4. You can go back to Symfony 4 to implement a dedicated route loader for generating appropriate routes to legacy scripts. Route loader extends internal Symfony Loader service. It’s used to generate routes that are understandable for the Symfony framework. In this case, the route loader should load all the front controller scripts of the legacy application with appropriate routes. For example, it will take a legacy/web/app.php and make a route /app that points to LegacyController with proper parameters. The code below is an exemplary implementation I made for the article.

Let’s go through the implementation of a route loader step by step. In the beginning, you need to find all the script files in order to set their pathnames to the Route object. You can do this using the Finder service. In this case, legacy scripts are placed in the legacy/web directory.

...
$finder = new Finder(); 
$finder->sortByName(); 
$finder->files()->name('*.php'); 

/** @var SplFileInfo $legacyScriptFile */ 
foreach ($finder->in($this->webDir) as $legacyScriptFile) { 
    ... 
} 
...

Then, as you have a collection of script files you need to create a route name for each one (Route object requires a unique route name).

...
$filename = basename($legacyScriptFile->getRelativePathname(), '.php'); 
$routeName = sprintf('app.legacy.%s', str_replace('/', '__', $filename)); 
...

Going forward you need to create a Route object itself and add it to the RouteCollection object.

...
if ("index.php" === $legacyScriptFile->getRelativePathname()) { 
    $collection->add('app.legacy.home_index', new Route("/{path}", [ 
        '_controller' => "App\Controller\LegacyController::loadLegacyScript", 
        'requestPath' => '/' . $legacyScriptFile->getRelativePathname(), 
        'legacyScript' => $legacyScriptFile->getPathname(), 
    ], ['path' => '.*'])); 
     continue; 
} 
...

The last thing you need to add to your route loader is the supports method. This method checks if the current loader supports the given resource. You can name the resource type legacy_routes.

...
public function supports($resource, $type = null): bool 
{ 
    return $type === 'legacy_routes'; 
} 
... 

final result should look like the following 

<?php declare(strict_types=1); 

namespace App; 

use Symfony\Component\Config\Loader\Loader; 
use Symfony\Component\Finder\Finder; 
use Symfony\Component\Finder\SplFileInfo; 
use Symfony\Component\Routing\Route; 
use Symfony\Component\Routing\RouteCollection; 

class LegacyRouteLoader extends Loader 
{ 
    const LEGACY_CONTROLLER = "App\Controller\LegacyController::loadLegacyScript"; 
    const DEFAULT_WEB_DIR = __DIR__ . "/../legacy/web";   

    /** 
     * @var string 
     */ 

    private $webDir; 

    public function __construct(?string $webDir = null) 
    { 
        $this->webDir = $webDir ?? self::DEFAULT_WEB_DIR; 
    }   

    public function load($resource, $type = null): RouteCollection 
    { 
        $collection = new RouteCollection(); 
        $finder = new Finder(); 
        $finder->sortByName(); 
        $finder->files()->name('*.php');   

        /** @var SplFileInfo $legacyScriptFile */ 
        foreach ($finder->in($this->webDir) as $legacyScriptFile) { 
            $filename = basename($legacyScriptFile->getRelativePathname(), '.php'); 
            $routeName = sprintf('app.legacy.%s', str_replace('/', '__', $filename));   

            if ("index.php" === $legacyScriptFile->getRelativePathname()) { 
                $collection->add('app.legacy.home_index', new Route("/{path}", [ 
                    '_controller' => self::LEGACY_CONTROLLER, 
                    'requestPath' => '/' . $legacyScriptFile->getRelativePathname(), 
                    'legacyScript' => $legacyScriptFile->getPathname(), 
                ], ['path' => '.*'])); 
                continue; 
            }   

            $collection->add($routeName, new Route($legacyScriptFile->getFilenameWithoutExtension() . "{path}", [ 
                '_controller' => self::LEGACY_CONTROLLER, 
                'requestPath' => '/' . $legacyScriptFile->getRelativePathname(), 
                'legacyScript' => $legacyScriptFile->getPathname(), 
            ], ['path' => '.*'])); 
        }   

        return $collection; 
    }   

    public function supports($resource, $type = null): bool 
    { 
        return $type === 'legacy_routes'; 
    } 
} 

In the end, you need to edit config/routes.yaml file to add a piece of information about legacy_routes and define the loader which generates them (the one we created above). It’s worth mentioning that the order of defined routes in the configuration implies the order of executing them. It means that if both applications have the same route, the earlier one will have priority.

legacy_routes: 
    resource: App\LegacyRouteLoader::loadRoutes 
    type: legacy_routes

Prepare Legacy Controller 

To use routes generated by route loader, you will have to create a controller that handles these routes. Route loader will use this controller during routes creation (you might have noticed the _controller attribute in the previous example). Before you start to implement the controller, you need to think about what responsibility this controller should have. First of all, you’d like to run a proper legacy front controller script related to an incoming request. To do this you need to have a request path which is a relative pathname to target script. You also need a piece of information about the root folder of this script. You need this data to simulate a direct call to the legacy application.

At the beginning of an implementation, you need to create a controller method. This method will accept two arguments that were defined when creating the Route object.

public function loadLegacyScript(string $requestPath, string $legacyScript): Response 
{ 
    … 
} 

Then, to simulate a direct call, you need to set some attribute values in a $_SERVER global variable. These are PHP_SELF, SCRIPT_NAME, and a SCRIPT_FILENAME.

$_SERVER['PHP_SELF'] = $requestPath; 
$_SERVER['SCRIPT_NAME'] = $requestPath; 
$_SERVER['SCRIPT_FILENAME'] = $legacyScript; 

It’ll help the legacy front controller to properly process an incoming request and return the response.

Next, you need to change directory to the one a given script is located (using chdir function) and load the legacy script using a require statement. This script will process the given request and give access to the sfContext object in the current script, which you can use to retrieve the response.

chdir(dirname($legacyScript));   

require $legacyScript; 
/** @var \sfWebResponse $legacyResponse */ 
/** @var \sfContext $sfContext */ 
$legacyResponse = $sfContext->getResponse(); 

You’re almost done. Almost because Symfony 4.0 doesn’t understand Symfony 1.4 response object, so you have to create a new one using the old one. You have to remember to set the information like status code and a content type to keep consistency between old and modern ones.

$response = new Response($legacyResponse->getContent(), $legacyResponse->getStatusCode()); 
$response->headers->set('Content-Type', $legacyResponse->getContentType()); 

return $response;

An exemplary implementation with some refactoring and extra method for removing trailing slashes (for the URLs backward compatibility) of the controller should look like the one below

<?php declare(strict_types=1); 

namespace App\Controller; 

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; 
use Symfony\Component\HttpFoundation\Response; 

class LegacyController extends AbstractController 
{ 
    public function loadLegacyScript(string $requestPath, string $legacyScript): Response 
    { 
        $_SERVER['PHP_SELF'] = $requestPath; 
        $_SERVER['SCRIPT_NAME'] = $requestPath; 
        $_SERVER['SCRIPT_FILENAME'] = $legacyScript; 
        $_SERVER['REQUEST_URI'] = $this->removeTrailingSlashes($_SERVER['REQUEST_URI']); 

        chdir(dirname($legacyScript)); 

        require $legacyScript; 
        /** @var \sfWebResponse $legacyResponse */ 
        /** @var \sfContext $sfContext */ 
        $legacyResponse = $sfContext->getResponse(); 
        $sfContext->shutdown(); 

        return $this->prepareResponse($legacyResponse); 
    } 
  
    private function prepareResponse(\sfWebResponse $legacyResponse): Response 
    { 
        $response = new Response($legacyResponse->getContent(), $legacyResponse->getStatusCode()); 
        $response->headers->set('Content-Type', $legacyResponse->getContentType()); 

        return $response; 
    } 

    private function removeTrailingSlashes(string $path): string 
    { 
        return rtrim($path, '/'); 
    } 
} 

Once you have implemented the controller and route loader, it’s time to adjust the configuration of the framework. You need to edit config/services.yaml and configure a tag for route loader service to run it during route generation.

App\LegacyRouteLoader: 
    tags: [routing.loader]

What about a user session? 

In more complex solutions there’s a need to use the same session data. It’s much easier if both applications can share session information. Symfony 1.4 session has a different structure than the newer one. Looking at the Symfony 1.4 user class (it extends sfBasicSecurityUser) you can see that there is a method initialize. This method initiates a user by reading session data from the sfStorage and set them to the current user. You can take advantage of this feature and override it to get the Symfony 4 session data and set it to the current user.

Demo application 

I created a git repository with the complete solution described in this article. The project is available at GitHub (link). Feel free to ask any questions you have. You can comment on this article or contact me on GitHub. I’m happy to talk about your approach to this topic and we can try to solve your problem together.

References

Dawid Kuśmierek
About

I'm a software developer who thinks a programming language is just a tool. New technologies enthusiast. I'm mainly interested in software engineering and databases.