Passer d’un applicatif historique au dernier Symfony 7 ne se résume pas à une réécriture. La clé est de migrer par paliers, sans régression et sans arrêt de service. Cet article propose une démarche concrète — strangler pattern, couche d’anti‑corruption, tests de non‑régression — et un outillage prêt à l’emploi pour livrer vite et en sécurité.
Cartographier le legacy et tracer les “seams”
Avant de coder, mesurez. Identifiez les endroits où vous pouvez brancher du nouveau sans casser l’ancien.
- Flux métier prioritaires: top 5 parcours qui génèrent du revenu ou du risque.
- Interfaces entrantes: HTTP, CLI, batch, files MQ.
- Interfaces sortantes: DB, services externes, fichiers plats.
- Contrats implicites: formats JSON/XML, conventions d’URL, erreurs, timeouts.
Conseils pratiques:
- Extrayez les logs d’accès des 30 derniers jours et classez par popularité/latence.
- Figez un “contrat” pour chaque endpoint: schéma OpenAPI capturé depuis le legacy via un proxy
- Repérez les seams (césures) exploitables au sens de Michael Feathers: frontières techniques, modules faiblement couplés, points d’extension.
Architecture cible: ports/adapters et Symfony 7 comme orchestration
Votre cible n’est pas “un gros contrôleur Symfony”, mais un noyau métier isolé, piloté par des ports, et des adapters côté I/O.
- Domain (pure PHP): règles, Value Objects, invariants.
- Ports: interfaces du domaine (ex. CustomerRepositoryPort).
- Adapters: HTTP, DB, file systems, legacy.
- Application: cas d’usage (services) et orchestration (contrôleurs, CLI, Messenger si nécessaire).
Outillage utile:
- PHPStan/Psalm pour fixer des contrats stricts.
- Deptrac pour verrouiller les dépendances entre modules.
Strangler pattern en pratique avec Symfony 7
Le “strangler fig pattern” consiste à placer un proxy devant le legacy: toute requête passe par le nouveau, qui n’en traite qu’une partie, et délègue le reste à l’ancien. Au fil des itérations, la proportion “nouveau” augmente, jusqu’à étouffer complètement le legacy.
Option 1 — Split au niveau du reverse proxy
- NGINX/Traefik répartit par chemin ou header:
- /auth et /profile => Symfony 7
- tout le reste => legacy
- Avantage: très performant, peu intrusif.
- Inconvénient: duplication de logique de routage si la granularité devient fine.
Exemple NGINX simplifié:
location ~ ^/(auth|profile) {
proxy_pass http://symfony7;
}
location / {
proxy_pass http://legacy;
}
Option 2 — Catch‑all côté Symfony (proxy applicatif)
Installez un “catch‑all” qui ne s’active que si aucune route Symfony ne correspond. Il forwarde la requête vers le legacy et renvoie la réponse telle quelle.
<?php
// src/Infrastructure/Http/LegacyGateway.php
namespace App\Infrastructure\Http;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class LegacyGateway
{
public function __construct(
private HttpClientInterface $client,
private string $legacyBaseUrl
) {}
public function forward(Request $request): Response
{
$uri = rtrim($this->legacyBaseUrl, '/') . $request->getRequestUri();
$options = [
'headers' => $request->headers->all(),
'body' => $request->getContent(),
'timeout' => 10,
];
$legacy = $this->client->request($request->getMethod(), $uri, $options);
return new Response(
$legacy->getContent(false),
$legacy->getStatusCode(),
$legacy->getHeaders(false)
);
}
}
<?php
// src/Controller/StranglerProxyController.php
namespace App\Controller;
use App\Infrastructure\Http\LegacyGateway;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class StranglerProxyController
{
#[Route('/{path}', name: 'strangler_proxy', requirements: ['path' => '.*'], priority: -100)]
public function __invoke(Request $request, LegacyGateway $gateway): Response
{
// Si aucune autre route n’a matché, on délègue au legacy
return $gateway->forward($request);
}
}
- Avantage: logique de split versionnée dans le code.
- Inconvénient: un léger coût de latence.
Astuce: ajoutez un header de traçage (X-Strangled-By: symfony7) pour suivre le pourcentage de trafic migré.
Anti‑corruption layer: ne laissez pas le legacy “polluer” votre modèle
L’ACL traduit les concepts et formats du legacy vers votre domaine propre. C’est une façade d’intégration qui protège vos invariants.
Exemple de port et d’adapter:
<?php
// src/Domain/Customer/Port/CustomerRepositoryPort.php
namespace App\Domain\Customer\Port;
use App\Domain\Customer\Model\Customer;
interface CustomerRepositoryPort
{
public function byId(string $id): ?Customer;
}
<?php
// src/Infrastructure/Legacy/CustomerLegacyAdapter.php
namespace App\Infrastructure\Legacy;
use App\Domain\Customer\Model\Customer;
use App\Domain\Customer\Port\CustomerRepositoryPort;
final class CustomerLegacyAdapter implements CustomerRepositoryPort
{
public function __construct(private LegacyClient $client) {}
public function byId(string $id): ?Customer
{
$raw = $this->client->get('/customers/'.$id); // array: ['cust_id'=>'00123','name'=>'ACME','created_at'=>'2020-01-02']
if (!$raw) {
return null;
}
return new Customer(
id: ltrim((string)$raw['cust_id'], '0'),
name: (string)$raw['name'],
createdAt: new \DateTimeImmutable($raw['created_at'])
);
}
}
Points clés:
- Ne réutilisez pas les DTO legacy tels quels. Créez vos propres Value Objects et normalizers.
- Gérez explicitement les écarts (formats de dates, arrondis monétaires, encodages).
- Logguez et tracez toute “correction” appliquée par l’ACL pour détecter des dérives de données.
Tests de non‑régression: comparer pour ne pas casser
Votre meilleur allié: des tests qui garantissent que “nouveau” et “ancien” produisent la même chose, à périmètre égal.
Golden master pour endpoints
- Capturez la réponse du legacy et figez‑la comme référence.
- Le nouveau code doit produire la même réponse (à champs volatils près).
public function test_show_customer_is_backward_compatible(): void
{
$legacy = $this->getFromLegacy('/customers/123');
$symfony = $this->getFromSymfony('/customers/123');
$this->assertSameJson($this->normalize($legacy), $this->normalize($symfony));
}
private function normalize(string $json): array
{
$data = json_decode($json, true);
unset($data['updatedAt'], $data['traceId']);
return $data;
}
- Pour fiabiliser, rejouez les tests sur des jeux de données anonymisés issus de la prod.
Contract testing
- Déclarez le contrat (OpenAPI) validé par les deux mondes.
- Ajoutez une étape CI “openapi-diff” pour garantir qu’aucune rupture non désirée n’est introduite.
Mutation testing et couverture utile
- Avec Infection, assurez‑vous que vos tests attrapent de vraies erreurs, pas seulement la forme.
- Concentrez la couverture sur les cas d’usage migrés et l’ACL.
Découpage modulaire et stratégie de migration
Migrez par fonctionnalités complètes (parcours utilisateur) plutôt que par couches techniques.
- Choisissez un flux ciblé, “à forte valeur, faible dépendance” (ex. profil utilisateur).
- Isolez ses dépendances externes et créez les ports correspondants.
- Implémentez l’ACL minimale pour ce flux.
- Activez le routing strangler: 1% du trafic réel en canary, puis 10%, 50%, 100%.
- Éteignez l’implémentation legacy pour ce flux.
Pour structurer le code:
- Structure par features: src/Customer, src/Order, src/Shared.
- Un package Composer par module si votre organisation est monorepo (path repositories).
- Enforcez les règles d’accès avec Deptrac: Customer ne peut pas dépendre de Order, etc.
Plus tard, vous pourrez enrichir vos workflows métier avec Symfony Workflow ou orchestrer des traitements asynchrones avec Messenger, comme décrit dans nos articles complémentaires.
Migrations de base de données sans régression
Adoptez l’approche “expand‑and‑contract” pour garder la compatibilité entre deux versions de code.
Phase 1 — Expand (ajouts non bloquants):
- Ajouter colonnes/tables facultatives.
- Écrire dans les deux colonnes (ancienne et nouvelle).
- Backfiller en tâche à part.
final class Version2025XXXXXX extends \Doctrine\Migrations\AbstractMigration
{
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE customers ADD name VARCHAR(255) DEFAULT NULL');
// Backfill asynchrone recommandé; en dernier recours:
$this->addSql('UPDATE customers SET name = full_name WHERE name IS NULL');
// Optionnel: trigger DB pour garder les colonnes en sync selon SGBD
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE customers DROP name');
}
}
Phase 2 — Switch:
- Le code lit la nouvelle structure.
- Feature flag pour activer la lecture “new schema only”.
Phase 3 — Contract (retraits):
- Supprimer l’ancienne colonne une fois 100% du trafic migré et le backfill vérifié.
Bonnes pratiques DB:
- Migrer en petites étapes transactionnelles.
- Pour MySQL, privilégier des online schema changes (pt-online-schema-change, gh-ost) sur tables massives.
- Sur PostgreSQL, créer des index CONCURRENTLY.
- Tester les migrations sur des volumes réalistes via Testcontainers en CI.
Livraison incrémentale, feature flags et shadow traffic
- Feature flags (Unleash, LaunchDarkly ou maison via env vars) pour activer/désactiver un flux migré.
- Shadow traffic: dupliquez une partie du trafic prod vers le nouveau code, comparez les réponses sans les renvoyer à l’utilisateur, logguez les diffs.
- Canary releases: exposez 1‑5% des utilisateurs au nouveau chemin, surveillez, puis augmentez progressivement.
Exemple de garde‑fou simple:
if ($this->flags->isEnabled('customer_v2')) {
return $this->newController->show($id);
}
return $this->legacyProxy->forward($request);
Observabilité, performance et rollbacks
- Logs centralisés (Monolog + enrichissement de contexte: feature flag, version, traceId).
- Traces distribuées (OpenTelemetry + exporter OTLP) pour visualiser la portion legacy vs nouvelle.
- Erreurs et alerting (Sentry) configurés par feature.
- Profilage ciblé des parcours migrés (Blackfire) pour éviter toute régression de latence.
Préparez le rollback:
- Flags réversibles.
- Déploiements atomiques (blue/green).
- DB backward compatible durant toute la fenêtre de canary.
Outils recommandés pour accélérer
- Rector pour upgrader progressivement le code PHP et adopter des API Symfony 7.
- PHPStan/Psalm niveau strict, baseline commitée.
- Deptrac pour les frontières modulaires.
- Doctrine Migrations + outils d’OSC (gh-ost/pt-osc) selon SGBD.
- Infection (mutation testing) et OpenAPI‑Diff pour la conformité contractuelle.
- Symfony HttpClient pour le proxy legacy; Sentry/OTel pour la visibilité.
Feuille de route type en 6 semaines
- Semaine 1: cartographie, logs d’accès, contrats OpenAPI, pipeline CI/CD prêt.
- Semaine 2: squelette Symfony 7, modules, Deptrac, ACL minimale, strangler proxy en place.
- Semaine 3: premier flux migré derrière flag, golden master, shadow traffic.
- Semaine 4: canary 10→50→100%, backfill DB, monitoring serré.
- Semaine 5: bascule lecture sur nouveau schéma, suppression code legacy du flux.
- Semaine 6: rétrospective, dette technique, choix du prochain flux.
Conclusion
Moderniser un legacy vers Symfony 7 sans régression, c’est avant tout une stratégie: strangler pattern pour contrôler le trafic, anti‑corruption pour protéger votre domaine, tests et observabilité pour livrer sereinement. Outillés convenablement, vous pouvez migrer par incréments, sans big‑bang ni frictions business.
Envie d’un plan adapté à votre contexte et d’un kickstart technique? Contactez‑nous pour un atelier d’audit et une stratégie de migration sur mesure, ou abonnez‑vous à la newsletter pour nos prochains retours d’expérience.