How we migrated from Nette to Symfony like ninjas
Two people, 13 working days, 712 files, and production on Valentine’s Day. Switching a framework under a running product sounds scary. With the right tools, it can be fun.
Why?
Because it’s cool. Because we could. And the real motivation? Frustration.
The app was the backend of Entrydo, an API used by our web and mobile apps. It was a four-year-old Nette application, but when you looked at what made it a Nette app, there wasn’t that much left. A lot of it was Symfony already, wrapped in Nette DI extensions:
extensions:
console: Kdyby\Console\DI\ConsoleExtension
doctrineEvents: Kdyby\Events\DI\EventsExtension
annotations: Kdyby\Annotations\DI\AnnotationsExtension
doctrine: Kdyby\Doctrine\DI\OrmExtension
migrations: Nettrine\Migrations\DI\MigrationsExtension
monolog: Kdyby\Monolog\DI\MonologExtension
rabbitmq: Kdyby\RabbitMq\DI\RabbitMqExtension
translation: Kdyby\Translation\DI\TranslationExtension
translatable: Zenify\DoctrineBehaviors\DI\TranslatableExtension
events: Contributte\EventDispatcher\DI\EventDispatcherExtension
env: wodCZ\NetteDotenv\DotEnvExtension
Console, events, Doctrine, Monolog, translations: all Symfony components or Doctrine underneath, with a Nette bridge on top. The actual Nette part was nette/di, neon, bootstrap, application, component-model, http, utils and finder, all at 2.4. So it was more like there was some Nette in our Symfony app.
How the hell did we do that?
The joke answer: a year earlier there was already a commit called “Now we are running on symfony”. It added a single catch-all route and this controller:
class NetteController
{
public function __invoke()
{
$legacyFile = __DIR__ . '/../../nette/www/index.php';
ob_start();
include($legacyFile);
$content = ob_get_clean();
return new Response($content);
}
}
Technically, Symfony. Not what I mean.
How we really did it
With Rector, the tool that changes PHP code automatically through its syntax tree, and a lot of new Rector rules written for this exact migration. The final diff was 712 files, 23,636 lines added and 31,855 removed.
The timeline:
- First commit on 27 January 2019 (most of the work happened from 7 February)
- Last commit on 13 February 2019
- 13 working days in total
- On production on 14 February 2019. Happy Valentine’s Day.
The last night was the dramatic one. At 1:41 I suspected where the last problem was. At 4:18 I had it. At 8:07 we were live and working.
What Rector changed
A few samples of what the rules did across the codebase. Presenters stopped creating Nette response objects and got a response factory injected instead:
- public function __construct(CancelEventUseCase $useCase, RequestBodyHelper $requestBodyHelper)
+ public function __construct(CancelEventUseCase $useCase, RequestBodyHelper $requestBodyHelper, \Entrydo\App\Responses\ResponseFactory $responseFactory)
- return new OKResponse($response);
+ return $this->responseFactory->createJsonResponse($response);
Event subscribers moved from our own request event to Symfony’s kernel events:
- return [RequestEvent::NAME => 'updateNewRelicTransactionName'];
+ return [\Symfony\Component\HttpKernel\KernelEvents::REQUEST => 'updateNewRelicTransactionName'];
- public function updateNewRelicTransactionName(RequestEvent $event): void
+ public function updateNewRelicTransactionName(GetResponseEvent $event): void
The New Relic transaction name, which used to be the Nette presenter name, became the HTTP method plus the path. And a plain string $requestId passed through the constructor became an injected RequestIdProvider service.
Out of the work came new open-source pieces: a NEON to YAML converter for the configs, many new Rector rules, and a whole Nette-to-Symfony set in Rector.
What we still had to do by hand
ErrorPresenterbecame anExceptionSubscriber- Tests moved to SymfonyTestBundle
- Renaming and moving files
- Replacing services in the DI container with mocks in tests
The WTFs
- Every event subscriber got called twice.
- Doctrine mapping files had to be renamed from
Entity.dcm.xmltoEntity.orm.xml. - Fixtures changed from
extends Fixturetoextends AbstractFixture. - Translations lost their placeholders, because Symfony wants the percent signs in the parameter key:
'name' => 'Tom'had to become'%name%' => 'Tom'.
The ultimate 20-hour WTF
Domain events combined with Doctrine fixtures. We dispatched domain events from Doctrine’s postFlush() event, and on Symfony that ended in a loop. I still don’t understand how it worked in Nette. The fix, once found, was small: dispatch domain events on KernelEvents::TERMINATE, after the response, instead of on flush. The commit message was “[Holy shit!] Domain events are dispatched in TERMINATE instead of onFlush”.
Switching framework can be fun
The two people were me and Tomáš Votruba, the author of Rector. He wrote up the details from his side in a series of posts and in a summary of the whole migration.
All 32 slides
Related talks
- Migrating a legacy PHP project to PSR-4 with scripts and Rector · 2019
- Build your own GitHub bot · 2019
- PHP app architecture: Symfony + DDD + CQRS, no event sourcing needed · 2022
Video (Facebook) Rector on GitHub Tomáš Votruba’s write-up PSR-4 migration talk All talks





























