How to Migrate Spaghetti to 304 Symfony 5 Controllers Over Weekend

During Easter weekend, usually, people take a break and have a rest. Instead, we used these 4 days of holiday to migrate the 304-controller application. At least that was the goal on Friday.

Me in my colleague in the migrated project accepted the challenge. We got into many minds and code-traps. We'd like to share this experience with you and inspire those who are still stuck on non-MVC code and think it might take weeks or even months to switch to a framework.

What is the Goal?

We didn't want a hybrid with static dependency injection container, legacy controller, request separation for new website and for old website. It only creates more legacy code than in the beginning.

❌✅ We were ok with keeping original business logic code untouched. We will handle spaghetti decoupling to Controller and Twig in the next phase. This was just a 1st step of many.

We wanted to be able to use Symfony dependency injection, Twig templates, Controller rendering, Symfony Security, Events, Repository, connection to database, .env, Flex, Bundles, YAML configs, local packages.

We wanted automate everything that is possible to automate.

We wanted to run on Symfony 5.0 and PHP 7.4.

We wanted to write any future code as if in any other Symfony application without going back.


Well, we wanted a full-stack framework, as you can find in symfony/demo.

Isn't that too much for one weekend? 😂

"Only those who attempt the absurd can achieve the impossible."
Albert Einstein

Honestly, I'm just freaking lazy to do work for a longer time than a few days (in a row).

The Application in The Start

So how does the application look like?

Symfony documentation describes a controller as a PHP function you create that reads information from the Request object and creates and returns a Response object. In our case, the "Request object" was an entry URL, "Response object" was spaghetti rendered as echo "string";.

Saying that the application had:


Typical controller looked like this:

<?php
// contact.php

include 'header.php';

$content = get_data_from_database();

// 500 lines of spaghetti code

echo $content;

First: Make a Plan

The migration pull-request itself is just half of the work. First, we had to have coding standards, PSR-4 autoloading, PHPStan on level 8 etc. When I say PHPStan on level 8, we skipped those errors with 50+ cases.

The next half is to have a full team on board and have a clear plan.

PHP Template in Symfony 5?

We had a goal, so what's the plan? First, we wanted to switch PHP + HTML to controllers. Maybe we could use something like PHP templates + render them with a controller?

The idea is great, except PHP templates were deprecated in Symfony 4 and removed in Symfony 5:

Raw Symfony Application

Hm, so what now? If it too huge, take something smaller. First, we need to actually have a Symfony project:

composer require symfony/asset symfony/cache symfony/console symfony/dotenv \
    symfony/flex symfony/framework-bundle symfony/http-foundation symfony/http-kernel \
    symfony/twig-bridge symfony/twig-bundle \
    symplify/autowire-array-parameter \
    symplify/package-builder twig/twig doctrine/cache symfony/security-core \
    symfony/security-bundle symfony/security-csrf doctrine/orm doctrine/doctrine-bundle \
    doctrine/annotations doctrine/common doctrine/dbal symfony/error-handler symfony/form
composer require --dev symfony/maker-bundle symfony/web-profiler-bundle

Few fixes of bundles installation that Flex missed, adding database credential to .env.local file to login into the database, and we're ready to continue with an uplifted spirit of success.

Soon to be demolished again by new problems we never faced before... Let's look at the controllers.


We wanted to use Rector to convert all the files to classic Symfony controllers.

The simple rule is: <filename>.php

What Will Be Inside Controller?

Just simply copy-paste the spaghetti code first, maybe that will be enough:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;

final class ContactController extends AbstractController
{
    /**
     * @Route(path="contact", name="contact")
     */
    public function __invoke(): Response
    {
        $content = get_data_from_database();
        // 500 lines of spaghetti code

        // this won't work, we need to return Response object :/
        echo $content;
    }
}

If the content would be echoed just once, we could use:

$content = get_data_from_database();
// 500 lines of spaghetti code

return new \Symfony\Component\HttpFoundation\Response($content);

But there is echo all over the place - like 50 times in those 500 lines of spaghetti code.


Then we remembered, there are ob_* functions that collect echoed content, but don't show it. If we wrap the spaghetti and get content with ob_get_contents() in the end, it might work.

ob_start();

// 500 lines of spaghetti code

$content = (string) ob_get_contents();
ob_end_clean();
return new \Symfony\Component\HttpFoundation\Response($content);

4 hours of writing a Rector rule for the migration and voilá - we had 304 new Symfony controllers:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;

final class ContactController extends AbstractController
{
    /**
     * @Route(path="contact", name="contact")
     */
    public function __invoke(): Response
    {
        ob_start();

        $content = get_data_from_database();
        // 500 lines of spaghetti code

        $content = (string) ob_get_contents();
        ob_end_clean();
        return new Response($content);
    }
}

That wasn't that hard. Let's run the website to enjoy the fruits of Eden:

Hm, maybe we should update all the links from contact.php to contact routes in every PHP file too. Also, all 304 links to all controller we just converted.

How to get Base Template into Templates?

Now when you entered https://localhost:8000/contact, you saw the raw page. From cool Symfony controller, but still a raw page. We wanted to use Twig templates, so we could enjoy filters, helpers, global variables, assets, etc.


This was our goal:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;

final class ContactController extends AbstractController
{
    /**
     * @Route(path="contact", name="contact")
     */
    public function __invoke(): Response
    {
        return $this->render('controller/contact.twig');
    }
}

In the end, that __invoke method is actually in every controller in this exact format. But we still miss one piece of the puzzle.

We also wanted to use normal base.twig, as we're used to in every MVC project:

<!DOCTYPE html>
<html>
    <head>
      {# some assets #}
   </head>
   <body>
      <div class="row">
         <div class="col-4">
            {% include "_snippet/menu.twig" %}
         </div>
         <div class="col-8">
              {% block main %}
              {% endblock %}
           </div>
       </div>
    </body>
</html>

What's inside the controller/contact.twig?

{% extends "base.twig" %}

{% block main %}
    PHP? Spaghetti? Magic?
{% endblock %}

How would you solve it? If you find a better way, let us know in the comments.

Remember: no PHP in Twig templates and no going back to Symfony 4.




We came up with this trick:

{% extends "base.twig" %}

{% block main %}
    {{ render(controller('App\\Controller\\ContractController::content')) }}
{% endblock %}

In each controller there will be not only the __invoke() method, but also the content method:

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;

final class ContactController extends AbstractController
{
    /**
     * @Route(path="contact", name="contact")
     */
    public function __invoke(): Response
    {
        return $this->render('controller/contact.twig');
    }

    /**
     * @Route(path="contact_content", name="contact_content")
     */
    public function content(): Response
    {
        ob_start();

        $content = get_data_from_database();
        // 500 lines of spaghetti code

        $content = (string) ob_get_contents();
        ob_end_clean();
        return new Response($content);
    }
}

With this approach, we have all we wanted:

We can use Symfony dependency injection, Twig templates, Controller rendering, Symfony Security, Events, Repository, connection to database, .env, Flex, Bundles, YAML configs, local packages.

We can to write any future code as if in any other Symfony application without going back.


To add chery on top, we added Symfony login:

And that's it!

Blind Paths to Avoid

Caveats

The Final Plan


We made many mistakes, took many blind paths, so you don't have to (you can take new blind paths), but in the end, we made it from Friday till Monday - in 4 days:

Are you still on a legacy project? What's your excuse that prevents your change for better?

If you have more questions, e.g., technical ones about the automated parts, let us know in the comments. We'll try to answer as best as we can.


Happy coding!




Do you learn from my contents or use open-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!