5 min de lecture
Migrer une application PHP legacy vers Symfony, sans tout réécrire
Une route « attrape-tout », un contrôleur de compatibilité et un layout partagé pour faire cohabiter l’ancien code et Symfony pendant toute une migration, page par page.
Mise à jour 2026 : cette approche est détaillée dans l’étude de cas BeeGreen, avec le contexte du projet et ce qu’elle a permis jusqu’en 2017.
Depuis 2012, je fais évoluer un SaaS dont le code date de 2009 : du PHP sans framework, des pages de plus de 1 000 lignes qui mélangeaient contrôle d’accès, SQL et HTML, et aucun test. L’application est utilisée tous les jours par des clients, et les demandes d’évolution ne s’arrêtent jamais.
Tout réécrire aurait gelé le produit pendant des mois, avec le risque de casser ce qui marchait. J’ai donc fait l’inverse : démarrer une application Symfony à côté de l’existant, puis en faire la porte d’entrée unique de l’application. Les pages migrées sont servies par Symfony, les autres continuent de tourner sur l’ancien code, et l’utilisateur ne voit pas la différence.
Voici comment ça fonctionne.
Une route « attrape-tout », déclarée en dernier
Tout repose sur une route qui accepte n’importe quelle URL, placée à la fin du fichier de routing :
# Toutes les routes migrées sont déclarées avant celle-ci.
legacy:
path: /{legacy_url}
defaults: { _controller: AppBundle:Legacy:index }
requirements:
legacy_url: .*Le routeur de Symfony teste les routes dans l’ordre. Si une page a déjà été réécrite, sa route est trouvée en premier. Sinon, la requête finit dans la route legacy, qui confie la page à l’ancien code.
Migrer une page devient alors très simple : on écrit la nouvelle route et son contrôleur, l’URL ne change pas, et on supprime l’ancien fichier.
Le contrôleur de compatibilité
Le contrôleur exécute le point d’entrée de l’ancienne application, capture ce qu’il affiche et le renvoie dans une réponse Symfony, avec les en-têtes HTTP qu’il a posés :
class LegacyController extends Controller
{
public function indexAction($legacy_url)
{
chdir($this->getParameter('legacy_dir'));
ob_start();
include 'site.php';
$legacyCode = ob_get_clean();
$headers = [];
foreach (headers_list() as $header) {
list($name, $value) = explode(':', $header, 2);
$headers[$name] = $value;
}
return new Response($legacyCode, http_response_code(), $headers);
}
}Le chdir() permet à l’ancien code de retrouver ses include relatifs. Et comme le fichier est inclus depuis une méthode du contrôleur, $this y désigne… le contrôleur. L’ancien code peut donc lire la configuration de Symfony ($this->getParameter('database_host')) ou l’URL demandée ($this->getRequest()->getRequestUri()), au lieu de garder ses propres fichiers de configuration.
Un cadre moderne, un contenu legacy
Renvoyer l’ancienne page telle quelle fonctionne, mais l’utilisateur passe d’un design à l’autre en changeant de page. J’ai donc ajouté un deuxième mode : le contenu de la page legacy est injecté dans le layout Twig de la nouvelle application.
/**
* @Template()
*/
public function indexWithCommonMenuAction($legacy_url)
{
// … même inclusion que ci-dessus …
return ['legacy_code' => $legacyCode];
}{% extends 'AppBundle::layout.html.twig' %}
{% block content %}
{{ legacy_code|raw }}
{% endblock %}Le menu, l’en-tête et la navigation sont ceux de la nouvelle application, seul le contenu vient de l’ancien code. Il a fallu quelques couches de compatibilité côté CSS :
- une feuille de style dédiée aux pages legacy, qui reprend l’en-tête et les mixins du nouveau design ;
- des règles de compatibilité isolées et commentées, pour savoir ce qu’on pourra supprimer plus tard :
/* For Legacy: to remove the day we remove legacy */
.ui-autocomplete {
z-index: 8888; /* display above modal */
}
/* End legacy */Il existe aussi un troisième mode, hybride : un formulaire de recherche écrit en Symfony au-dessus d’une liste encore générée par l’ancien code. C’est souvent par là que commence la migration d’un écran.
Une seule connexion
Les deux applications doivent partager l’utilisateur connecté. Au début, la connexion aux anciennes applications passait par des appels HTTP internes, fragiles et lents. Je les ai remplacés par un listener sur l’événement de connexion de Symfony, qui remplit la session attendue par l’ancien code : l’utilisateur, ses droits et son périmètre.
Les anciens mots de passe, hachés en MD5, sont vérifiés par un encodeur dédié : Symfony accepte les comptes existants sans que personne n’ait à changer de mot de passe.
Les pièges rencontrés
- La session. Une fois l’ancien code inclus, il n’est plus possible d’écrire dans la session côté Symfony : tout ce qui touche à la session doit être fait avant l’
include. - La route attrape-tout est gourmande. Elle capture tout ce qui n’a pas été déclaré avant elle, y compris les routes ajoutées par des bundles. L’ordre de chargement du routing doit donc être surveillé de près.
- Les fichiers statiques. Les images, CSS et JavaScript de l’ancien code doivent rester accessibles. Une commande console crée les liens symboliques nécessaires à chaque installation.
Et la base de données ?
La base de données reste la même pendant toute la migration. Les tables existantes ont été décrites avec des entités Doctrine, puis les évolutions de schéma sont passées par Doctrine Migrations. Les filtres Doctrine se chargent d’isoler les données de chaque client dans toutes les requêtes (voir Doctrine filters and annotations to improve security and ease development in a Symfony app).
Le bilan
Cette approche n’a rien de spectaculaire, et c’est justement son intérêt :
- le produit n’est jamais gelé : les nouvelles fonctions sortent pendant que l’ancien code recule ;
- chaque page migrée est un petit changement, facile à tester et à annuler ;
- on peut s’arrêter à tout moment. Certaines pages, trop risquées à réécrire, restent sur l’ancien code, nettoyées. Une migration n’a pas besoin d’être complète pour être utile.
Mise à jour 2026 : la documentation de Symfony consacre désormais une page entière à ce sujet, Migrating an Existing Application to Symfony. Elle décrit une approche proche, avec un
LegacyController, et la rattache au motif du Strangler Fig de Martin Fowler. Les anciens mots de passe ont ensuite été migrés vers un algorithme sûr, à la connexion de chaque utilisateur (voir Migrating users to a secure hashing algorithm in Symfony). Et si c’était à refaire aujourd’hui, je commencerais par écrire des tests de bout en bout sur les écrans critiques avant de les migrer, pour vérifier que le comportement reste identique.