diff --git a/index.rst b/index.rst index df3e3ac12e7..72e414e4c32 100644 --- a/index.rst +++ b/index.rst @@ -44,6 +44,7 @@ Topics logging mercure messenger + migration performance profiler routing diff --git a/migration.rst b/migration.rst new file mode 100644 index 00000000000..86b4feb0225 --- /dev/null +++ b/migration.rst @@ -0,0 +1,416 @@ +.. index:: + single: Migration + +Migrating an Existing Application to Symfony +============================================ + +When you have an existing application that was not built with Symfony, +you might want to move over parts of that application without rewriting +the existing logic completely. For those cases there is a pattern called +`Strangler Application`_. The basic idea of this pattern is to create a +new application that gradually takes over functionality from an existing +application. This migration approach can be implemented with Symfony in +various ways and has some benefits over a rewrite such as being able +to introduce new features in the existing application and reducing risk +by avoiding a "big bang"-release for the new application. + +Prerequisites +------------- + +The following steps do not require you to have the new Symfony +application in place and in fact it might be safer to introduce these +changes beforehand in your existing application. + +Before you start introducing Symfony to the existing application, you +have to ensure certain requirements are met by your existing application. +By extension this means that you will have to decide which version you +are aiming to migrate to, either a current stable release or the long +term support version (LTS). The main difference is, how frequently you +will need to upgrade in order to use a supported version. In the context +of a migration, other factors, such as the supported PHP-version or +support for libraries/bundles you use, may have a strong impact as well. +Using the most recent, stable release will likely give you more features, +but it will also require you to update more frequently to ensure you will +get support for bug fixes and security patches and you will have to work +faster on fixing deprecations to be able to upgrade. + +.. tip:: + + When upgrading to Symfony you might be tempted to also use + :doc:`Flex `. Please keep in mind that it primarily + focuses on bootstrapping a new Symfony application according to best + practices regarding the directory structure. When you work in the + constraints of an existing application you might not be able to + follow these constraints, making Flex less useful. + +First of all your environment needs to be able to support the minimum +requirements for both applications. In other words, when the Symfony +release you aim to use requires PHP 7.1 and your existing application +does not yet support this PHP version, you will probably have to upgrade +your legacy project. You can find out the +:doc:`requirements ` for running Symfony and +compare them with your current application's environment to make sure you +are able to run both applications on the same system. Having a test +system, that is as close to the production environment as possible, +where you can just install a new Symfony project next to the existing one +and check if it is working will give you an even more reliable result. + +.. tip:: + + If your current project is running on an older PHP version such as + PHP 5.x upgrading to a recent version will give you a performance + boost without having to change your code. + +In older PHP applications it was quite common to rely on global state and +even mutate it during runtime. This might have side effects on the newly +introduced Symfony application. In other words code relying on globals +in the existing application should be refactored to allow for both systems +to work simultaneously. Since relying on global state is considered an +anti-pattern nowadays you might want to start working on this even before +doing any integration. + +Another point you will have to look out for is conflicts between +dependencies in both applications. This is especially important if your +existing application already uses Symfony components or libraries commonly +used in Symfony applications such as Doctrine ORM, Swiftmailer or Twig. +A good way for ensuring compatibility is to use the same ``composer.json`` +for both project's dependencies. + +Once you have introduced composer for managing your project's dependencies +you can use its autoloader to ensure you do not run into any conflicts due +to custom autoloading from your existing framework. This usually entails +adding an `autoload`_-section to your composer.json and configuring it +based on your application and replacing your custom logic with something +like this:: + + require __DIR__ . '/vendor/autoload.php'; + +There might be additional steps you need to take depending on the libraries +you use, the original framework your project is based on and most importantly +the age of the project as PHP itself underwent many improvements throughout +the years that your code might not have caught on to, yet. As long as both +your existing code and a new Symfony project can run in parallel on the +same system you are on a good way. All these steps do not require you to +introduce Symfony just yet and will already open up some opportunities for +modernizing your existing code. + +Establishing a Safety Net for Regressions +----------------------------------------- + +Before we can safely make changes to the existing code we must ensure that we +don't break anything. One reason for choosing to migrate is making sure that +the application is in a state where it can run at all times. The best way for +ensuring a working state is to establish automated tests. + +It is quite common for an existing application to either not have a test suite +at all or have low code coverage. Introducing unit tests for this code is +likely not cost effective as the old code might be replaced with functionality +from Symfony components or might be adapted to the new application. +Additionally legacy code tends to be hard to write tests for making the process +slow and cumbersome. + +Instead of providing low level tests ensuring each class works as expected it +might make sense to write high level tests ensuring that at least anything user +facing works on at least a superficial level. These kinds of tests are commonly +called End-to-End tests, because they cover the whole application from what the +user sees in the browser down to the very code that is being run and connected +services like a database. In order to automate this you have to make sure that +you can get a test instance of your system running as easily as possible and +making sure that external systems do not change your production environment, +e.g. provide a separate test database with (anonymized) data from a production +system or being able to setup a new schema with a basic dataset for your test +environment. Since these tests do not rely as much on isolating testable code +and instead look at the interconnected system, writing them is usually easier +and more productive when doing a migration. You can then limit your effort on +writing lower level tests on parts of the code that you have to change or +replace in the new application making sure it is testable right from the start. + +There are tools aimed at End-to-End testing you can use such as +`Symfony Panther`_ or you can write :doc:`functional tests ` +in the new Symfony application as soon as the initial setup is completed. +For example you can add so called Smoke Tests, which only ensure a certain +path is accessible by checking the HTTP status code returned or looking for +a text snippet from the page. + +Introducing Symfony to the Existing Application +----------------------------------------------- + +The following instructions only provide an outline of common tasks for +setting up a Symfony application that falls back to a legacy application +whenever a route is not accessible. Your mileage may vary and likely you +will need to adjust some of this or even provide additional configuration +or retrofitting to make it work with your application. This guide is not +supposed to be comprehensive and instead aims to be a starting point. + +.. tip:: + + If you get stuck or need additional help you can reach out to the + :doc:`Symfony community ` whenever you need + concrete feedback on an issue you are facing. + +When looking at how a typical PHP application is bootstrapped there are +two major approaches. Nowadays most frameworks provide a so called +front controller which acts as an entrypoint. No matter which URL-path +in your application you are going to, every request is being sent to +this front controller, which then determines which parts of your +application to load, e.g. which controller and action to call. This is +also the approach that Symfony takes with ``public/index.php`` being +the front controller. Especially in older applications it was common +that different paths were handled by different PHP files. + +In any case you have to create a ``public/index.php`` that will start +your Symfony application by either copying the file from the +``FrameworkBundle``-recipe or by using Flex and requiring the +FrameworkBundle. You will also likely have to update you web server +(e.g. Apache or nginx) to always use this front controller. You can +look at :doc:`Web Server Configuration ` +for examples on how this might look. For example when using Apache you can +use Rewrite Rules to ensure PHP files are ignored and instead only index.php +is called: + +.. code-block:: apache + + RewriteEngine On + + RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ + RewriteRule ^(.*) - [E=BASE:%1] + + RewriteCond %{ENV:REDIRECT_STATUS} ^$ + RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] + + RewriteRule ^index\.php - [L] + + RewriteCond %{REQUEST_FILENAME} -f + RewriteCond %{REQUEST_FILENAME} !^.+\.php$ + RewriteRule ^ - [L] + + RewriteRule ^ %{ENV:BASE}/index.php [L] + +This change will make sure, that from now on your Symfony application is +the first one handling all requests. The next step is to make sure that +your existing application is started and taking over whenever Symfony +can not yet handle a path previously managed by the existing application. + +Front Controller with Legacy Bridge +................................... + +Once we have a running Symfony application that takes over all requests, +falling back to your legacy application is as easy as extending the +original front controller script with some logic for going to your legacy +system. The file could look something like this:: + + use App\Kernel; + use App\LegacyBridge; + use Symfony\Component\Debug\Debug; + use Symfony\Component\HttpFoundation\Request; + + require dirname(__DIR__).'/config/bootstrap.php'; + + /* + * The kernel will always be available globally, allowing you to + * access it from your existing application and through it the + * service container. This allows for introducing new features in + * the existing application. + */ + global $kernel; + + if ($_SERVER['APP_DEBUG']) { + umask(0000); + + Debug::enable(); + } + + if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? $_ENV['TRUSTED_PROXIES'] ?? false) { + Request::setTrustedProxies( + explode(',', $trustedProxies), + Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST + ); + } + + if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? $_ENV['TRUSTED_HOSTS'] ?? false) { + Request::setTrustedHosts([$trustedHosts]); + } + + $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG'], dirname(__DIR__)); + $request = Request::createFromGlobals(); + $response = $kernel->handle($request); + + /* + * LegacyBridge will take care of figuring out whether to boot up the + * existing application or to send the Symfony response back to the client. + */ + $scriptFile = LegacyBridge::prepareLegacyScript($request, $response, __DIR__); + if ($scriptFile !== null) { + require $scriptFile; + } else { + $response->send(); + } + $kernel->terminate($request, $response); + +There are 2 major deviations from the original file. First of all we make +``$kernel`` globally available. This allows us to use Symfony features inside +our existing application and gives access to services configured in our +Symfony application. This helps us prepare our own code to work better +within the Symfony application when we transition it over or to replace +outdated and redundant libraries with for example Symfony components. + +The legacy bridge, being called after the Symfony request is being handled, +is responsible for figuring out which file should be loaded in order to +process the old application logic. This can either be a front controller +similar to Symfony's ``public/index.php`` or a specific script file based +on the current route. The basic outline of this LegacyBridge could look +somewhat like this:: + + namespace App; + + use Symfony\Component\HttpFoundation\Request; + use Symfony\Component\HttpFoundation\Response; + + class LegacyBridge + { + public static function prepareLegacyScript(Request $request, Response $response, string $publicDirectory): string + { + /* + * If Symfony successfully handled the route, + * we do not have to do anything. + */ + if (false === $response->isNotFound()) { + return; + } + + /* + * Figure out how to map to the needed script file + * from the existing application and possibly (re-)set + * some env vars. + */ + + return $legacyScriptFilename; + } + } + +This is the most generic approach you can take, that is likely to work +no matter what your previous system was. You might have to account for +certain "quirks", but since your original application is only started +after Symfony finished handling the request you reduced the chances +for side effects and any interference. Since the old script is called +in the global variable scope it will reduce side effects on the old +code which can sometimes require variables from the global scope. +At the same time, because your Symfony application will always be booted +first, you can access the container via the ``$kernel`` variable and then +fetch any service. This can be helpful if you want to introduce new +features to your legacy application, without switching over the whole +action to the new application. For example, you could now use the +Symfony Translator in your old application or instead of using your old +database logic, you could use Doctrine to refactor old queries. This will +also allow you to incrementally improve the legacy code making it easier +to transition it over to the new Symfony application. + +The major downside is, that both systems are not well integrated +into each other leading to some redundancies and possibly duplicated code. +For example since the Symfony application is already done handling the +request you can not take advantage of kernel events, utilize Symfony's +routing for determining which legacy script to call. + + +Legacy Route Loader +................... + +The major difference to the LegacyBridge-approach from before is, that we +move the logic inside the Symfony application. Removing some of the +redundancies and allowing us to also interact with parts of the legacy +application from inside Symfony, instead of just the other way around. + +.. tip:: + + The following route loader is just a generic example that you might + have to tweak for your legacy application. You can familiarize + yourself with the concepts by reading up on it in :doc:`Routing `. + +The legacy route loader has a similar functionality as the previous +LegacyBridge, but it is a service that is registered inside Symfony's +routing component:: + + public function load($resource, $type = null) + { + $collection = new RouteCollection(); + $finder = new Finder(); + $finder->files()->name('*.php'); + + /** @var SplFileInfo $legacyScriptFile */ + foreach ($finder->in($this->webDir) as $legacyScriptFile) { + // This assumes all legacy files use ".php" as extension + $filename = basename($legacyScriptFile->getRelativePathname(), '.php'); + $routeName = sprintf('app.legacy.%s', str_replace('/', '__', $filename)); + + $collection->add($routeName, new Route($legacyScriptFile->getRelativePathname(), [ + '_controller' => 'App\Controller\LegacyController::loadLegacyScript', + 'requestPath' => '/' . $legacyScriptFile->getRelativePathname(), + 'legacyScript' => $legacyScriptFile->getPathname(), + ])); + } + + return $collection; + } + +This only shows the ``load``-method of a custom Route Loader. You will also +have to register the loader in your application's ``routing.yaml`` as +described in the documentation for :doc:`Custom Route Loaders `. +Depending on your configuration you might also have to tag the service with +``routing.loader``. Afterwards you should be able to see all the legacy routes +in your route configuration, e.g. when you call the ``debug:router``-command: + +.. code-block:: terminal + + $ php bin/console debug:router + +In order to use these routes you will need to create a controller that handles +these routes. You might have noticed the ``_controller`` attribute we attached +to our routes, which tells Symfony which Controller to call whenever we try +to access one of our legacy routes. The controller itself can then use the +attributes we passed to determine which script to call and wrap the output in +a response class:: + + class LegacyController + { + public function loadLegacyScript($requestPath, $legacyScript) + { + return StreamedResponse::create( + function () use ($requestPath, $legacyScript) { + $_SERVER['PHP_SELF'] = $requestPath; + $_SERVER['SCRIPT_NAME'] = $requestPath; + $_SERVER['SCRIPT_FILENAME'] = $legacyScript; + + chdir(dirname($legacyScript)); + + require $legacyScript; + } + ); + } + } + +This controller will set some server variables that might be needed by +the legacy application. This will simulate the legacy script being called +directly, in case it relies on these variables, e.g. when determining +relative paths or file names. Finally the action requires the old script, +which essentially calls the original script as before, but it runs inside +our current application scope, instead of the global scope. + +There are some risks to this approach, but since the legacy code now runs +inside a controller action we gain access to many functionalities from the +new Symfony application, including the chance to use Symfony's event +lifecycle. For instance, this allows us to transition the authentication +and authorization over to the Symfony application using the Security +component and its firewalls. + +Additional Resources +-------------------- + +The topic of migrating from an existing application towards Symfony is +sometimes discussed during conferences. For example the talk +`Modernizing with Symfony`_ reiterates some of the points from this page. + + +.. _`Strangler Application`: https://www.martinfowler.com/bliki/StranglerApplication.html +.. _`autoload`: https://getcomposer.org/doc/04-schema.md#autoload +.. _`Modernizing with Symfony`: https://youtu.be/YzyiZNY9htQ +.. _`Symfony Panther`: https://github.com/symfony/panther