· Péhápkaři Prague meetup #35 · Prague · given in Czech

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.

Update, 2026The Nette-to-Symfony Rector set we built for this migration was taken out of Rector core in 2021 and the package is archived. It targeted Nette 2.4/3 and Symfony 4, so treat it as material to learn from, not a tool to run on a current project.

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:

Terminal running PHPUnit 7.5.4 on 912 tests. The progress output starts with mostly passing dots, then turns into long rows of red E characters for errors from about 50% onwards, up to 693 of 912 tests at 75%.
What the test suite looked like somewhere in the middle.

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

The WTFs

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”.

Git history with four favourite commits: 7 February 2019 by Tomas Votruba, “init Rector autoload”; 11 February 2019, “We just killed Nette/Http in Entrydo - RIP”; 11 February 2019, “Getting rid of Nette/DI/Container in tests”; 13 February 2019, “[Holy shit!] Domain events are dispatched in TERMINATE instead of onFlush”.
My favourite commits, from “init Rector autoload” to the 20-hour bug.

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
  1. Slide 1: Title: Jan Mikeš, 14.03.2019, How we migrated from Nette to Symfony like ninjas, Entrydo logo
  2. Slide 2: Section title in large blue letters: Why?
  3. Slide 3: Cartoon alien meme captioned “Because it's cool.”
  4. Slide 4: Success kid meme: “Because we can!”
  5. Slide 5: Section title in large blue letters: Motivation?
  6. Slide 6: “Motivation?” struck through, replaced by “Frustration!”
  7. Slide 7: What makes Nette app a Nette app?
  8. Slide 8: Lot of Symfony stuff: Nette config extensions list of Kdyby and other Symfony bridges
  9. Slide 9: There is Nette in my Symfony app: composer.json with nette/* and kdyby/* packages
  10. Slide 10: How the hell did we do that?
  11. Slide 11: Git commit “Now we are running on symfony” on branch hi-symfony, 15/03/2018
  12. Slide 12: Diff of a Symfony NetteController that includes the legacy Nette index.php and wraps its output
  13. Slide 13: How we really did that
  14. Slide 14: Shrug meme: ¯\_(ツ)_/¯ with a man in a blue T-shirt shrugging
  15. Slide 15: What it takes? 712 files, 23636 additions, 31855 deletions
  16. Slide 16: PHPUnit run full of errors, 693 of 912 tests at 75%
  17. Slide 17: Timeline: first commit 27.1.2019, last 13.2.2019, 13 working days, on prod 14.2.2019
  18. Slide 18: Chat messages from the night of 13.02.19 ending with “we are live and working!!!”
  19. Slide 19: New open-source packages: Neon 2 YAML convertor; many new Rectors: Nette 2 Symfony set
  20. Slide 20: Few things still had to be done manually
  21. Slide 21: Manual work: ErrorPresenter → ExceptionSubscriber, SymfonyTestBundle, moving files, mocking services in tests
  22. Slide 22: Section title in large blue letters: WTF?
  23. Slide 23: Gotchas: event subscribers called twice, Entity.dcm.xml → Entity.orm.xml, Fixture → AbstractFixture, translations missing %%
  24. Slide 24: Ultimate 20 hour WTF: domain events + Doctrine fixtures, postFlush event, replaced with KernelEvents::TERMINATE
  25. Slide 25: Section title in large blue letters: Rector samples
  26. Slide 26: Rector diff: injecting ResponseFactory and returning createJsonResponse() instead of new OKResponse
  27. Slide 27: Rector diff: NewRelicSubscriber switching from Nette RequestEvent to Symfony KernelEvents::REQUEST
  28. Slide 28: Rector diff: replacing a string $requestId property with an injected RequestIdProvider
  29. Slide 29: Favorite commits: init Rector autoload, killed Nette/Http, removed Nette DI container in tests, TERMINATE fix
  30. Slide 30: Switching framework can be fun
  31. Slide 31: github.com/rectorphp/rector with the GitHub Octocat
  32. Slide 32: Homer Simpson asking “Questions?” and Thank you! entry.do, janmikes.cz, jan@entrydo.com

Related talks