From Symfony to Laravel - 5 Steps to Prepare your Symfony Project for Migration

Framework migration is a challenge few choose to take - yet in some cases, it makes sense for business, project health, and pure joy from coding.

Once you know the recipe, it's clear the switch is doable.

Today, we'll look at the steps to prepare your Symfony project for future Laravel migration.

"Luck favors the prepared... and brave."

We start with steps that make the Symfony project easier to maintain, then move to more Symfony-Laravel bridge topics.


1. Make sure your Configs are *.php

At first, we make sure our /config directory contains only PHP files. This will help tools like ECS, PHPStan, and Rector to see the PHP configs, check them for errors like missing classes, and automate any migration.

We can migrate YAML configs to PHP effortlessly with a single CLI command run - using symplify/config-transformer.

composer require symplify/config-transformer --dev
vendor/bin/config-transformer switch-format config

Don't forget to update your Kernel loader to seek PHP files and you're done.


2. Prepare a custom script for TWIG to Blade conversion

I've learned this trick from Pragmatic Programmer. What if we need to process a high volume of data in a reliable way?

We could rewrite templates manually - in fact, if our project has 10 TWIG files, that's the saint way.

For a higher volume of data, we should take a different path - create a one-time script to do one job and then perish.


Does it sound tedious and long-term work? Reality is pretty straightforward. For my projects, it took a weekend train trip from Cascais to Lisbon and back


Here is the script I used - feel free to steal it.


3. Understand the differences between Symfony and Laravel container

There are 2 important difference between these 2 containers:

First - the Symfony container is compiled into a huge PHP file and then dumped for caching purposes. This allows performance-heavy operations like service decoration, autowired setters, and compiler passes. The Laravel container is built on the fly - it's lighter and faster.

I've never noticed any difference in developer experience regarding this point.


The 2nd difference that I consider much more important is:

You can read about the upsides, downsides and how to deal with them in What I prefer about Laravel Dependency Injection over Symfony


I was most worried about the lack of Symfony compiler passes. Laravel docs don't mention them at all. Why not?

Once you know it, the reason is simple - different naming - Laravel is compiler passes-ready.


4. Create a parallel Laravel container

Would you cross this bridge to the other side?

img-thumbnail
The success of every more significant migration is based on stability.

That's why:


First, we ensure our project has a Symfony container factory in place. It will make flipping containers later on possible with a single line:

use Psr\Container\ContainerInterface;

final class SymfonyContainerFactory
{
    public function create(): ContainerInterface
    {
        $kernel = new AppKernel();
        $kernel->boot();

        return $kernel->getContainer();
    }
}

Notice the use of the PSR container contract.

We use this Symfony container factory at entry-point levels like bin/console, public/index.php or abstract test cases.


Then, we create LaravelContainerFactory to handle services for the Laravel context. We create this container in parallel to the Symfony container and keep the Symfony untouched:

use Psr\Container\ContainerInterface;
new Illuminate\Container\Container;

final class LaravelContainerFactory
{
    public function create(): ContainerInterface
    {
        $container = new Container();
        $container->singleton(SomeType::class);

        return $container;
    }
}

We register every non-standard service to the LaravelContainerFactory::create() method. This way, we can easily see what services are not registered in the Laravel container and add them later.


Now we have 2 containers - yay! But one of them is not used. One could call that's a dead code, right?

I'll show you how to use the other container in the next step.


5. Try the Laravel container in your tests

This is where the fast feedback loop kicks in. We'll be able to:

Fast and safe.

I've used this technique to migrate this website, getrector.com, all my open-source packages and CLI tools, ECS and finally Rector.

I was starting with a few weeks of real Laravel experience and learning mostly from error messages. That means if I can do it, you can do it too.


Again, we should have separated AbstractSymfonyTestCase that uses the Symfony container (or one based on KernelTestCase), and our container-based tests depend on it. We keep this file untouched:

use PHPUnit\Framework\TestCase;

abstract class AbstractSymfonyTestCase extends TestCase
{
    protected function setUp(): void
    {
        $symfonyContainerFactory = new SymfonyContainerFactory();

        // add static caching if needed
        $this->container = $symfonyContainerFactory->create();
    }
}

Then our tests use it like:

final class SomeTest extends AbstractSymfonyTestCase
{
    public function test(): void
    {
        $someType = $this->container->get(SomeType::class);

        $result = $someType->callForResult();
        $this->assertNotEmpty($result);
    }
}

Our goal is to replace the parent test from Symfony to Laravel one case and make the test pass simultaneously.


First, we create a new abstract test case, e.g., AbtractLaravelContainerTestCase where we'll use LaravelContainerFactory:

use PHPUnit\Framework\TestCase;

abstract class AbstractLaravelTestCase extends TestCase
{
    protected function setUp(): void
    {
        $laravelContainerFactory = new LaravelContainerFactory();

        // add static caching if needed
        $this->container = $laravelContainerFactory->create();
    }
}

The final step is to try to replace the parent class and re-run the test:

-final class SomeTest extends AbstractSymfonyTestCase
+final class SomeTest extends AbstractLaravelTestCase
 {
     public function test(): void
     {
         $someType = $this->container->get(SomeType::class);

         $result = $someType->callForResult();
         $this->assertNotEmpty($result);
     }
 }

We will not change any other line because that would turn our migration into a refactoring. The original test code must remain untouched and work for both containers.


We run the tests, see what fails, fix it, iterate the feedback loop, and create pull-request for our single test. Merge and repeat. Again, we should not touch the original source code, only the LaravelContainerFactory.

That's it!


Thanks to this technique, we made Rector tests 7x faster before we made the container switch. To give you perspective, the whole migration was finished within a week.


Happy coding!




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