Migrating Legacy Symfony 4 Applications: A Practical Guide
AI generated
SF
{ }
Symfony · Legacy migration · Flex · Modernization
Migrating Legacy Symfony 4 Applications
the practical guide for grown projects

A legacy Symfony 4 migration rarely fails because of missing knowledge about new features, it fails because of the old bundle structure, Sensio annotations and missing Flex. Knowing the order in which structure, configuration and dependencies get modernized brings an application that has grown for years safely to a current Symfony state.

20 min read Flex · Bundle structure · Sensio annotations · Autowiring Symfony 4 → 5 → 6 → 7

1. Why legacy Symfony 4 applications are special

A legacy Symfony 4 migration differs fundamentally from a regular minor upgrade because Symfony 4 comes from a time when Flex was still new and many conventions taken for granted today were not yet established. Projects started in 2018 or 2019 often still carry the old AppBundle pattern, manually registered services in YAML and the SensioFrameworkExtraBundle for annotation based routing. These structures still work technically, but they block any further progress toward current Symfony versions.

The second reason a legacy Symfony 4 migration needs particular care: three major version jumps lie between Symfony 4 and Symfony 7. Each of them has its own breaking changes, its own deprecation cycle and its own config format. A direct jump from 4 to 7 without intermediate steps is not technically supported and would not make sense in practice either, because the deprecation warnings of every intermediate version provide exactly the hints needed for a safe migration.

The third point concerns the team itself: many developers maintaining such an application today did not build the original Symfony 4 structure and no longer know the historical reasons behind certain decisions. A legacy Symfony 4 migration is therefore also an opportunity to replace lost architectural knowledge with documented, modern conventions instead of carrying it forward.

2. Inventory: bundle structure and dependencies

The first step of any legacy Symfony 4 migration is an honest inventory, not a code change. How many bundles does the application register in AppKernel.php or bundles.php? Which of them are custom, which come from third parties, and which are actively maintained? A bundle without commits for three years is a clear risk for the migration and should be marked as a replacement candidate early.

It is also worth analyzing the directory structure. Symfony 4 introduced the src/ convention instead of src/AppBundle/, but many projects migrated from Symfony 3 kept the old AppBundle out of inertia. For a clean legacy Symfony 4 migration, this structure has to be straightened out first, before deeper dependencies are considered, because many downstream automation tools such as Rector assume the standard directory structure.


# List all registered bundles, including deprecated Sensio bundles
grep -rn "Bundle::class" config/bundles.php app/AppKernel.php 2>/dev/null

# Find how many years since the last commit for a vendor bundle
composer show sensio/framework-extra-bundle
composer show knplabs/knp-menu-bundle

# Check for AppBundle-style legacy structure
find src -maxdepth 1 -type d -name "AppBundle"

3. Adopting Symfony Flex retroactively

Many legacy projects were started before Flex existed or never fully adopted it. Without Flex, automatic recipe management is missing, and every new package has to be configured manually, which leads to inconsistent config structures precisely during a legacy Symfony 4 migration. Adopting Flex retroactively is possible, but it requires composer.json and the directory structure to be aligned with Flex conventions before Flex can apply recipes to new packages.

The practical flow: Flex is installed as a Composer plugin, then existing packages are retrofitted one by one with composer recipes:install using their official recipes, where available. Not every old package has a matching recipe, in which case manual configuration remains, but new packages benefit from automatic configuration from that point on. This adoption is one of the most important levers in any legacy Symfony 4 migration, because it speeds up the entire remaining path.


# Install Flex as a Composer plugin in an existing Symfony 4 app
composer require symfony/flex

# Re-apply official recipes to already installed packages, where available
composer recipes
composer recipes:install symfony/monolog-bundle --force

# After Flex is active, config layout follows the config/packages/ convention
ls config/packages/

4. Replacing Sensio annotations with PHP attributes

The SensioFrameworkExtraBundle was the standard way for annotation based routing, @ParamConverter and security annotations in Symfony 4. Since Symfony 6, all of these features are available as native PHP attributes in the Symfony core itself, and the bundle is considered obsolete. A legacy Symfony 4 migration has to replace these annotations step by step with PHP attributes, because the bundle is no longer actively developed in newer Symfony versions and will eventually become incompatible.

The conversion is mechanical but extensive: every @Route annotation becomes a #[Route] attribute, every @ParamConverter use gets replaced by native entity autowiring through #[MapEntity]. For large controller directories it is worth using Rector with the matching Sensio rule set, which automates this transformation and produces a reviewable diff instead of editing every file by hand.


<?php

declare(strict_types=1);

namespace App\Controller;

// BEFORE: SensioFrameworkExtraBundle annotations (Symfony 4 legacy style)
// use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
// use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
//
// /**
//  * @Route("/product/{id}", name="product_show")
//  * @ParamConverter("product", class="App\Entity\Product")
//  */
// public function show(Product $product): Response { ... }

use App\Entity\Product;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ProductController
{
    // AFTER: native PHP attributes, no Sensio bundle required
    #[Route('/product/{id}', name: 'product_show')]
    public function show(#[MapEntity] Product $product): Response
    {
        return new Response($product->getName());
    }
}

5. Autowiring instead of manual service definitions

Symfony 4 started with autowiring as the default, but many older projects still contain large services.yaml blocks with manually resolved constructor arguments from the Symfony 2 or Symfony 3 era. These manual definitions are a maintenance risk for a legacy Symfony 4 migration, because every change to a constructor requires a parallel change in the YAML file that is easily forgotten.

The cleanup is best done incrementally: first autowire: true and autoconfigure: true are enabled for the default namespace, then explicit service definitions are removed one by one and the test suite runs after every change. For services with multiple implementations of the same interface, explicit definitions with an alias remain necessary, but the bulk of legacy definitions can simply be deleted once autowiring is configured correctly.

6. The staged plan: 4 → 5 → 6 → 7 instead of a direct jump

The central strategic point of any legacy Symfony 4 migration is avoiding a direct jump. Composer technically does not allow a version constraint that jumps from 4.4 straight to 7.0 while resolving all intermediate versions compatibly, but even if it were possible, it would be the wrong strategy. Every Symfony version between 4 and 7 marks certain APIs as deprecated before they are removed two versions later. Without these intermediate steps, the warnings that show which code needs adjustment are missing.

The proven flow: first update to Symfony 4.4 as the last 4.x release, fix all deprecations, then move to 5.4 LTS, fix deprecations again, then to 6.4 LTS, and only at the end to 7. Every intermediate version is verified with a running test suite before the next jump happens. This legacy Symfony 4 migration across four stations takes longer than a single upgrade step, but it produces significantly fewer unexpected errors, because each version on its own has a manageable deprecation delta.


# Stage 1: land on the last Symfony 4 release, fix all deprecations
composer require symfony/symfony:^4.4
SYMFONY_DEPRECATIONS_HELPER=weak php bin/phpunit

# Stage 2: move to Symfony 5.4 LTS
composer require symfony/symfony:^5.4
SYMFONY_DEPRECATIONS_HELPER=weak php bin/phpunit

# Stage 3: move to Symfony 6.4 LTS
composer require symfony/symfony:^6.4
SYMFONY_DEPRECATIONS_HELPER=weak php bin/phpunit

# Stage 4: final jump to Symfony 7
composer require symfony/symfony:^7.0
php bin/phpunit

7. Securing Doctrine migrations and data consistency

A legacy Symfony 4 migration affects not only code, it frequently also touches a Doctrine mapping grown over years with inconsistent naming conventions, outdated annotation based entity definitions and migration files that were partly edited manually. Before the code migration, it is worth comparing the actual database schema against the Doctrine migration files with doctrine:migrations:diff, to uncover hidden discrepancies that would otherwise surface as a surprise during the migration.

Doctrine entity annotations in the old @ORM\Column style still work, but should be converted to PHP attributes as part of the legacy Symfony 4 migration, since annotations are set to disappear from the Doctrine core in the medium term. This conversion can largely be automated with Rector's Doctrine annotation rule set and should happen alongside the Sensio attribute migration, since both use the same toolset.

8. Common risks in legacy migration

The biggest risk of a legacy Symfony 4 migration is insufficient test coverage. Projects from the Symfony 4 era were often built without consistent functional tests, because a testing culture only took hold later in many teams. Without tests, there is no way to verify whether a staged migration actually preserved behavior, and regressions are only noticed by users in production.

A second common risk is forgotten cron jobs and console commands that run outside the HTTP request cycle and are therefore easily overlooked during manual browser testing. A third risk is hardcoded paths to old bundle directories in deployment scripts that break after the directory cleanup. A careful legacy Symfony 4 migration therefore inventories not only code and bundles, but also all accompanying scripts and automation.

9. Migration strategies compared directly

The table below compares three possible strategies for a legacy Symfony 4 migration and shows when each approach makes sense.

Strategy Approach Risk When suitable
Staged migration 4 → 5 → 6 → 7 Each version separately, deprecations fixed Low Default case for actively maintained apps
Direct jump with manual fixes Fix all breaking changes at once High Only for very small, well tested apps
Strangler fig rebuild New Symfony 7 app, legacy retired gradually Medium, but lengthy Very large, heavily overgrown legacy systems

For most projects, the staged migration is the right choice, because it keeps the risk small without requiring the effort of a complete rebuild. A strangler fig approach only pays off if the legacy Symfony 4 migration is meant to go hand in hand with a fundamental architecture overhaul anyway.

Mironsoft

Legacy modernization and multi stage Symfony migrations

Ready to modernize an old Symfony 4 application safely?

We handle the complete legacy Symfony 4 migration, from the inventory through the staged migration to the switch to Flex, attributes and autowiring, with full test coverage.

Legacy audit

Inventory of bundles, structure and outdated dependencies

Staged migration

Controlled path from Symfony 4 through 5 and 6 to Symfony 7

Test coverage

Retrofitting functional tests wherever coverage is missing

10. Summary

A successful legacy Symfony 4 migration starts with an honest inventory of the bundle structure, followed by retroactively adopting Flex and gradually retiring Sensio annotations in favor of native PHP attributes. Autowiring replaces manual service definitions, and Doctrine mappings are converted from annotations to attributes in parallel.

The decisive strategic point is avoiding a direct jump: the path through 4.4, 5.4 LTS and 6.4 LTS to Symfony 7 uses the deprecation warnings of every intermediate version as a safety net. Following this order and securing every stage with a running test suite reliably brings even heavily grown legacy applications to a modern Symfony state.

Legacy Symfony 4 Migration — the essentials at a glance

Inventory first

Document the bundle list, directory structure and dependency age before touching any code.

Retrofit Flex

Automatic recipe management for all future packages, possible even retroactively.

Staged plan, no direct jump

4.4 → 5.4 LTS → 6.4 LTS → 7, close every stage with a green test suite.

Attributes instead of annotations

Convert Sensio and Doctrine annotations to native PHP attributes, ideally with Rector.

11. FAQ: Migrating Legacy Symfony 4 Applications

1Direct jump from 4 to 7?
Not sensible technically, intermediate versions provide important deprecation warnings for a safe migration.
2Most important first step?
Honest inventory of bundles, structure and dependency age before any code change.
3Adopt Flex retroactively?
Strongly recommended for automatic recipe management and consistent configuration.
4What happens to the Sensio bundle?
Considered obsolete since Symfony 6, should be replaced gradually by native PHP attributes.
5How long does 4 to 7 take?
Several weeks to a few months, depending on project size and test coverage.
6Missing test coverage?
Retrofit functional tests for critical paths before migration.
7Rector for Sensio conversion?
Yes, with a matching rule set, but always review the diff manually.
8Staged vs. strangler fig?
Staged migration updates the same base, strangler fig builds new and retires legacy gradually.
9Old AppBundle structure?
Convert to the src/ convention early, before applying automation tools.
10Convert Doctrine annotations too?
Yes, convert to PHP attributes alongside the Sensio migration.