How we Migrated 54 357-lines Application from Nette to Symfony in 2 People under 80 Hours
Tomáš VotrubaIt would take us 3 full-time months to rewrite this code in 2017. In February 2019 we did it in less than 3-week span with the help of automated tools. Why and how?
This post was originally published in Czech on Zdrojak.cz, where it got huge attention of Czech PHP community and hit amazing 54 comments (a possible record). But when I talk about this migration with my English speaking PHP friends, it seems crazy to them and they want to hear details - who, how, when, what exactly?
This post is for you (and for you of course, if you haven't read it on Zdroják).
What Have We Migrated?
Backend of Entry.do project - API application built on controllers, routing, Kdyby integrations of Symfony, Doctrine and a few Latte templates. The application has been running in production for last 4 years. We migrated from Nette 2.4 to Symfony 4.2.
How big is it? If we don't count tests, migration, fixtures, etc., the application has 270 PHP files in the length of 54 357 lines (using phploc).
How many unique routes does it have? 20...? 50...? 151! Just to have an idea, the pehapkari.cz website has 35 routes.
Why?
The application was written in Nette, which worked and met the technical requirements. The main motivation for the transcription was the dying ecosystem and that over-integration of Symfony. What does "dying ecosystem" mean? Nette released just 1 minor version since July 2016, while Symfony had 6 releases during the same period.
80% of the extensions are just glue integrations of Symfony and Doctrine
Nette was just Controllers, Routing and Dependency-Injection
Why use unmaintained integrations of Kdyby and Zenify, that only integrate Symfony to Nette\DI, if Symfony is already there? Last new minor version of Nette was published 3 years ago. Symfony releases new minor version every 6 months with new features will make your work easier.
How?
I offered Honza Mikeš deal he couldn't refuse:
"We will give it a week and if we stuck, we'll give up".
On the January 27th, we met with his Nette application and on February 13th the Symfony application went to the staging server. In less than 17 days we were done and on February 14th we celebrated a new production application in addition to Valentine's Day.
The final size of migration pull-request
In fact, we were talking about migration at the beginning of 2017, because the Nette ecosystem wasn't really developing and Symfony was technologically skipping it. At that time, however, the transition would last at least 80-90 days for full-time, which is insane, so we didn't go into it.
Tool Set
In 2019 we already have a lot of tools to do the work for you:
-
The first is Rector, tool I made that can change any code that runs at least on PHP 5.3 from pattern A to pattern B. It can instantly update the code from PHP 5.3, 5.4, 5.5, 5.6... to 7.4, Symfony from 2.8 to 4.2, Laravel from static code to constructor injection, and more. You can add your own rules tailored to migrate your specific code, that can handle anything that PHP programmer can do (A → B) in a fraction of the time.
-
The second is NeonToYamlConverter - as you can guess, it converts NEON syntax to YAML
-
The third assistant is LatteToTwigConverter - it migrates Latte files to TWIG syntax
During those 17 days we put in 80 hours of work for both of us together (= 40 hours each).
20 % of Good Old Manual Work
Although we do not like it, we had to do 20 % of the migration manually.
One of the first steps was to move from config programming to PHP programming. Both frameworks try to promote their own sugar syntax for Neon or YAML. It sounds cool to new programmers, to write less code, but it's confusing, framework-specific, can be done in clear PHP anyway, and most importantly, static analysis and instant refactoring won't deal with it.
How does "config programming" look like?
services:
- FirstService(@secondService::someMethod())
Or also:
services:
-
class: 'Entrydo\Infrastructure\Payment\GoPay\NotifyUrlFactory'
arguments:
- '@http.request::getUrl()::getHostUrl()'
What common PHP pattern, that is framework-agnostic and almost everyone knows, can we use?
Factory!
<?php
final class FirstServiceFactory
{
/**
* @var SecondService
*/
private $secondService;
public function __construct(SecondService $secondService)
{
$this->secondService = $secondService;
}
public function create()
{
return new SomeService($this->secondService);
}
}
What did we Gained with This Refactoring?
- Constructor injection!
- Framework independence - when we migrate to another framework or pattern in 3 years, we don't have to deal with this file anymore.
- Static analysis works
- More testable code, thanks to PHP code instead of config
- PHPStorm refactoring works
- Rector works
- It's a clear PHP code
In Nette and Symfony, several things were different:
- ErrorPresenter → ExceptionSubscriber
- Use SymfonyTestBundle for tests
- Moving Files to Symfony 4 Single-Level Structure
- Exchange of services in mock tests
- In Nette, there is a Request/Response service, but in Symfony, it's an object
- Rewrite extension configs to Flex configs
80 % of Work Automated
Another 80% of the pull-request you saw above was done by automatic tools. The first one was enough to write, the other one to set it up.
Neon to YAML
Neon and YAML are de facto fields with minor differences in syntax, but when it comes to services, each framework writes a little differently. Config with services had 316 lines in the services section. You don't want to migrate it manually, the Neon entities. In addition, just one error in related migration and you can do it all over again.
I took few hours and wrote Symplify/NeonToYamlConverter. Just pass the path to the *.neon
file and it will convert into beautiful *.yaml
file.
PHP Migration
Again to the factory pattern - there were several custom Response classes in the code that inherited from Nette Response and added extra logic. We could edit them manually one by one, but it was easier to extract them into the factory method:
<?php
class SomePresenter
{
+ /**
+ * @var ResponseFactory
+ */
+ private $responseFactory;
+
+ public function __construct(ResponseFactory $responseFactory)
+ {
+ $this->responseFactory = $responseFactory;
+ }
+
public function someAction()
{
- return new OKResponse($response);
+ return $this->responseFactory->createJsonResponse($response);
}
}
Honza created new NewObjectToFactoryCreateRector
rule that handled this.
What else was left?
- Move the routing from
RouterFactory
to particular Controller actions - Rename
Request
andResponse
classes + including their codes (POST
,GET
,200
...) - Rename classes and methods on
Nette\DI\Container
,Nette\Configurator
,Nette\Application\IPresenter
etc, - Changing parent classes on Controllers,
- Renaming Controllers to
*Controller
(they use "Presenter" naming in Nette) - Move namespaces from
App\Presenter
toApp\Controller
The most changes were in controllers:
<?php declare (strict_types = 1);
-namespace App\Presenter;
+namespace App\Controller;
-use Nette\Application\AI\Presenter;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
-use Nette\Http\Request;
+use Symfony\Component\HttpFoundation\Request;
-final class SomePresenter extends Presenter
+final class SomeController extends AbstractController
{
- public static function someAction()
+ public static function someAction(Request $request)
{
- $header = $this-> httpRequest-> getHeader('x');
+ $header = $request-> headers-> get('x');
- $method = Request::POST;
+ $method = Request::METHOD_HPOST
}
}
Syntax Sugar? Syntax Hell
For a while, Kdyby\Translation screwed us with "syntax sugar". In the Nette application, the listing of variables (Tom) worked for us:
- "Hi, my name is Tom"
But in Symfony magically added %%
:
- "Hi, my name is% Tom%"
WTF? After 15 minutes we figured it out - Kdyby\Translation wrapped the variable name in "%%" for you - and fixed it:
<?php
class SomePresenter
{
public function someAction()
{
// Kdyby/Translation differnce to native Symfony/Translation
$this->translations->translate('Hi, my name is %name%', [
- 'name' => 'Tom',
+ '%name%' => 'Tom'
]);
}
}
Pretty cool, huh?
Rename Event Names
We also cannot forget the rename of events from Contribute\Events to Symfony KernelEvents:
From RouterFactory
to Controller @Route
annotation
RouteFactory is single class in Nette to define all routes for all controllers and their actions. In Symfony, this is quite the opposite. You define the routes directly at the Controller action. And to make matters worse it uses annotation.
What with this? Well, one option is to move one route at a time - all 151. To make it even more challenging, we had our own RestRoute
and our own RouteList
, including POST/GET/..., which Nette doesn't have.
How does one change look like?
<?php
namespace App;
use Entrydo\RestRouteList;
use Entrydo\Restart;
final class RouterFactory
{
- private const PAYMENT_RESPONSE_ROUTE = '/ payment / process';
// 150 more!
public function create()
{
$router = new RestRouteList();
- $router[] = RestRoute::get(self::PAYMENT_RESPONSE_ROUTE, ProcessGPWebPayResponsePresenter::class);
// 150 more!
return $router;
}
}
namespace App Presenter;
+use Symfony\Component\Routing\Annotation\Route;
final class ProcessGPWebPayResponsePresenter
{
+ /**
+ * @Route(path = "/payments/gpwebpay/process-response", methods="GET"})
+ */
public function __invoke()
{
// ...
}
}
Now do this 151 times... and make rebase-proof. When we first talked about the migration in 2017, we would make all these changes manually. Too lazy to work.
And in 2019? For a few days, we were preparing the nette-to-symfony
Rector set and then run it on the entire code base:
composer require rector/rector -dev
vendor/bin/rector process app src --level nette-to-symfony
And it is done :)
Everything we've learned during the 17-day migration is in this set and this post. Just download Rector and you can use the set straight away.
From Valentine's Day to the nette-to-symfony set, a complete migration from Nette Tester to PHPUnit and the migration of Nette Forms to Symfony Forms and Component to Controllers have been added.
Final Touches
After a lot of static content changes, the code worked and the tests went through, but it looked a bit messy. Spaces were missing, fully qualified class names were not imported, etc.
You can use your own PHP_CodeSniffer and PHP-CS-Fixer set. We used the Rector-prepared set of 7 rules with EasyCodingStandard:
vendor/bin/ecs check app src --config vendor/rector/rector/ecs-after-rector.yaml
It's not about the Work, It's about the Knowledge
And so we migrated a 4-years old Nette application of 54 357 lines under 80 hours to Symfony and put it into production. The most of the time took us debugging of events and writing migration rules and tools. Now the same application would take us (or you) 10 hours top to migrate.
As you can see, any application can be migrate from one framework to another under a month. Dare us!