How to migrate a Legacy application to Symfony ?


The quick and easy way

Posted by Pedro Resende on 12/04/2017 19:10

Sometime you might want to migrate from an existing PHP application to Symfony in order to continue the development of your old spaghetti code application.

You have two possibilities to do that:

  1. Rewrite all the controllers, routes and create entities for all your existing tables that sometimes give you problems due to missing relations or primary keys or
  2. You can encapsulate your old application into a bundle

In this post I’ll address the second approach since in the majority of the cases it will take too much time and the costs will be too high to rewrite all the application.

We’ll start by create a new bundle to deal with the legacy application and it’s controller

$ php bin/console generate:bundle --namespace=LegacyBundle --dir=src --no-interaction 

it will output something like the following  

Bundle generation  

> Generating a sample bundle skeleton into app/../src/LegacyBundle   

created ./app/../src/LegacyBundle/   
created ./app/../src/LegacyBundle/LegacyBundle.php   
created ./app/../src/LegacyBundle/Controller/   
created ./app/../src/LegacyBundle/Controller/DefaultController.php   
created ./app/../tests/LegacyBundle/Controller/   
created ./app/../tests/LegacyBundle/Controller/DefaultControllerTest.php   
created ./app/../src/LegacyBundle/Resources/views/Default/   
created ./app/../src/LegacyBundle/Resources/views/Default/index.html.twig   
created ./app/../src/LegacyBundle/Resources/config/   
created ./app/../src/LegacyBundle/Resources/config/services.yml 

> Checking that the bundle is autoloade 
> Enabling the bundle inside app/AppKernel.php   
updated ./app/AppKernel.php 
> Importing the bundle's routes from the app/config/routing.yml file   
updated ./app/config/routing.yml 

> Importing the bundle's services.yml from the app/config/config.yml file   
updated ./app/config/config.yml  
Everything is OK! 
Now get to work :).   

Let's start by removing the DefaultController.php, since it's not needed and create a new one

$ rm src/LegacyBundle/Controller/DefaultController.php 

$ touch src/LegacyBundle/Controller/LegacyController.php 

Inside our new controller let's add the following:

<?php 

namespace LegacyBundle\Controller; 

use Symfony\Bundle\FrameworkBundle\Controller\Controller; 
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 
use Symfony\Component\HttpFoundation\File\File; 
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; 
use Symfony\Component\HttpFoundation\Request; 
use Symfony\Component\HttpFoundation\Response; 
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; 
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; 
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; 

class LegacyController extends Controller { 

    /** 
     * This method is responsible for returning the legacy content 
     * 
     * @param  Symfony\Component\HttpFoundation\Request $request Symfony's request 
     * @param  string  $path    The Path where the fetch the file 
     * @return Symfony\Component\HttpFoundation\Response           Symfony's response 
     */ 
    public function fallbackAction(Request $request, $path)
     { 
        $response = new Response(); 
        $legacyContent = __DIR__.'/../legacy_app'.$request->getRequestUri(); 
        if (substr($legacyContent, -1) == '/') { 
            $legacyContent .= 'index.php'; 
        } 

        $arguments = strpos($legacyContent, '?'); 
        if ($arguments != 0) { 
            
            $legacyContent = substr($legacyContent, 0, $arguments); 
            if (strpos($legacyContent, 'index.php') == 0) { 
                $legacyContent .= 'index.php'; 
            } 
        } 

        try { 
            return $this->returnResponse($legacyContent, $response, $request); 
        } catch(FileNotFoundException $e) { 
            throw new NotFoundHttpException("Page not found $e"); 
        } 
    } 

    private function returnResponse($legacyContent, $response, $request) 
    { 
        $file = new File($legacyContent); 
        if ($file->getExtension() == 'php') { 

            ob_start(); 

            include $legacyContent; 

            $content = ob_get_clean(); 

            $authenticationHelper = $this->get('authentication.helper'); 

            if ($_SESSION['logged']) { 
                $authenticationHelper->authenticateInSymfony($_SESSION['id'], $request); 
            } else { 
                $authenticationHelper->logout(); 
            } 
        } else { 
            $content = file_get_contents($legacyContent); 
            if (strpos($legacyContent, '.css')) { 
                $response->headers->set('Content-Type', 'text/css'); 
            } else { 
                $response->headers->set('Content-Type', $file->getMimeType()); 
            } 

            $response->setPublic(); 
            $response->setMaxAge(3600); 
            $date = new \DateTime(); 
            $date->modify('+3600 seconds'); 

        } 

        $response->setStatusCode(Response::HTTP_OK); 
        $response->setContent($content);

        return $response; 
    } 
} 

Let's analyse a bit the code, the fallbackAction method is responsible for dealing with the request.

$legacyContent = __DIR__.'/../legacy_app'.$request->getRequestUri(); 
if (substr($legacyContent, -1) == '/') { 
    $legacyContent .= 'index.php'; 
}

this chunk of code will verify if the $legacyContent variable ends with a "/" and if it does, it will append 'index.php' at the end.

$arguments = strpos($legacyContent, '?'); 
if ($arguments != 0) { 
    $legacyContent = substr($legacyContent, 0, $arguments); 

    if (strpos($legacyContent, 'index.php') == 0) { 
        $legacyContent .= 'index.php'; 
    } 
}

This will remove all the parameters from the url, because if we have parameters from the provided URL it won't match an existing file.

The method returnResponse($legacyContent, $response, $request) is responsible for dealing with the content itself. If the file existing I’ll start by verifying if it is a PHP file and if it is it will buffer the output into a $content variable and will deal with the authentication.

On the other hand, if it isn’t a PHP file, then I’ll fetch the file contents and store it using the file_get_contents and at the end I’ll return the response. In the first glance, this seems to be very simple however I’ll describe a bit more the problems that I’ve found with this approach:

  • Session sharing between Symfony and legacy application
  • Concerning global variables inside the legacy application
  • Authentication in Symfony through the legacy application
  • Catching the path, including the slash “/“  

In order to address the problem of sharing sessions I’ve followed the documentation about Bridge a legacy Application with Symfony Session

The Global variables, you’ll have to declare them before starting the ob_start() and then it will work perfectly

About the authentication, it is actually really simple and this is how I’ve implemented.

I’ve declared a new service, inside service.yml:

services:
    authentication.helper:
        class: LegacyBundle\Helper\AuthenticationHelper
        arguments: ["@security.token_storage", "@event_dispatcher", "@security.authorization_checker"]

And the content of the AuthenticationHelper is the following:

<?php 

namespace LegacyBundle\Helper;

use Symfony\Component\HttpFoundation\Request; 
use Symfony\Component\Security\Core\Authentication\Token\AnonymousToken;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; 
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; 
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent; 

class AuthenticationHelper { 
    private $securityTokenStorage;
    private $eventDispatcher;
    private $securityAuthorizationChecker;

    public function __construct($securityTokenStorage, $eventDispatcher, $securityAuthorizationChecker) { 
        $this->securityTokenStorage = $securityTokenStorage; 
        $this->eventDispatcher = $eventDispatcher;
        $this->securityAuthorizationChecker = $securityAuthorizationChecker;
    } 

    /**
     * 
     * This method is responsible for authenticating a user if it isn't authenticated yet 
     *
     * @param  int  $userId  The user Id of the user to authenticate
     * @param  Request $request The request      
     * @param  string  $level   The level can be either ROLE_USER or ROLE_ADMIN      
     */     
    public function authenticateInSymfony($userId = null, Request $request, $level = 'ROLE_USER') 
    { 
        if (!$this->securityAuthorizationChecker->isGranted('IS_AUTHENTICATED_FULLY')) { 
            if ($userId == null) { 
                throw new UsernameNotFoundException('User not found');
            } else {
                $token = new UsernamePasswordToken($userId, null, 'main', [$level]);
                $this->securityTokenStorage->setToken($token);

                //now dispatch the login event
                $event = new InteractiveLoginEvent($request, $token); 
                $this->eventDispatcher->dispatch('security.interactive_login', $event); 
            } 
        } 
    } 

    /**      
     * This method is responsible for logging out the user      
     */ 
    public function logout()
    { 
        if ($this->securityAuthorizationChecker->isGranted('IS_AUTHENTICATED_FULLY')) { 
            $token = new AnonymousToken(null, 'anon.', ['IS_AUTHENTICATED_ANONYMOUSLY']); 
            $this->securityTokenStorage->setToken($token); 
        }
    } 
}

Finally, the path. This one took me awhile but it was quite simple, in the routing.yml file you only need to add

legacy_site:
    path: /{path}
    defaults: { _controller: LegacyBundle:Legacy:Legacy }
    requirements: 
        path: .*

P.S. - Just added the github repository with this article's tutorial.