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.
❌ 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."
Honestly, I'm just freaking lazy to do work for a longer time than a few days (in a row).
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:
include "header.php";
Typical controller looked like this:
<?php
// contact.php
include 'header.php';
$content = get_data_from_database();
// 500 lines of spaghetti code
echo $content;
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.
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:
Hm, so what now? If it too huge, take something smaller. First, we need to actually have a Symfony project:
remove vendor
remove composer.lock
install Symfony dependencies:
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
dev
too: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
filename
filename
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.
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!
bin/console
, src/AppKernel.php
and public/index.php
files.env.local
and login to database*
templates*_content.twig
templatesold_controller_files.json
old_controller_files.json
and
preg_replace
in all files to replace contact.php
to contact
, files names to routesold_controller_files.json
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!