Bloquer les versions obsolètes - Symfony
Cette partie décrit la mise en place du contrôle de version côté backend avec Symfony.
L’objectif est d’intercepter chaque requête venant du client (Flutter), de lire le header X-App-Version, puis de comparer sa valeur à une version minimale définie dans la configuration.
Si la version est trop ancienne → le serveur renvoie un HTTP 426 Upgrade Required.
La version intégrale (structure de répertoires, tests, configuration) est disponible sur Bitbucket :
➡ Symfony : https://bitbucket.org/doingfr/doing-cookbooks-examples/src/main/enforce-app-version/symfony/
➡ Flutter (côté client, pour l’interception) : https://bitbucket.org/doingfr/doing-cookbooks-examples/src/main/enforce-app-version/flutter/
Les extraits ci-dessous sont volontairement condensés pour la lecture. Reporte-toi au dépôt pour le contexte complet.
Pré-requis
Avant de mettre en place ce mécanisme:
- Définis une convention de version (SemVer simplifié
major.minor.patch). - Prépare la clé de traduction
version.app_version_upgrade_required. - Vérifie que
X-App-Versiontraverse proxy/CDN/WAF. - Décide du comportement si le header est absent (ici: permissif).
- Configure les environnements (
.env.local, staging, prod, test).
Commence avec APP_FORCE_UPDATE_ENABLED=false en production pour mesurer la répartition des versions avant le blocage.
Traduction
version:
app_version_upgrade_required: 'Une mise à jour est requise pour utiliser cette application.'
version:
app_version_upgrade_required: 'An upgrade is required to use this application.'
Variables par environnement
- Dev
- Staging
- Prod
APP_MIN_MOBILE_VERSION=0.0.0
APP_FORCE_UPDATE_ENABLED=false
APP_MIN_MOBILE_VERSION=2.3.0
APP_FORCE_UPDATE_ENABLED=true
APP_MIN_MOBILE_VERSION=2.3.2
APP_FORCE_UPDATE_ENABLED=true
Pourquoi ne pas bloquer en dev ?
Pour accélérer les tests locaux et éviter d’ajuster la variable à chaque scénario.
Étapes de mise en place
Configuration
Ajoute deux variables dans ton fichier .env ou .env.local :
APP_MIN_MOBILE_VERSION=2.3.0
APP_FORCE_UPDATE_ENABLED=true
Déclare-les dans config/services.yaml :
parameters:
app.min_mobile_version: '%env(APP_MIN_MOBILE_VERSION)%'
app.force_update_enabled: '%env(bool:APP_FORCE_UPDATE_ENABLED)%'
Service de comparaison de version
Un service simple basé sur la sémantique major.minor.patch
<?php
namespace App\Service\Version;
final class VersionComparator
{
/**
* Retourne -1 si $a < $b, 0 si égal, 1 si $a> $b
*/
public function compare(string $a, string $b): int
{
$na = $this->normalize($a);
$nb = $this->normalize($b);
return $na <=> $nb;
}
/**
* Normalize a version string to an array of integers
*
* @return array<int>
*/
private function normalize(string $v): array
{
// Garde que major.minor.patch numériques, ignore prérelease/build
$v = preg_replace('/[^0-9\.]/', '', $v) ?: '0.0.0';
$parts = array_map('intval', array_pad(explode('.', $v), 3, 0));
return [$parts[0], $parts[1], $parts[2]];
}
}
Listener de vérification
Le listener se branche sur kernel.request.
Il intercepte chaque requête HTTP et bloque celles dont la version est inférieure à la version minimale.
<?php
declare(strict_types=1);
namespace App\Events\EventListener\Version;
use App\Service\Version\VersionComparator;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsEventListener(event: KernelEvents::REQUEST, priority: 10)]
final class EnforceMobileVersionListener
{
public function __construct(
private readonly VersionComparator $comparator,
private readonly string $minVersion, // injecté via param
private readonly bool $enabled, // injecté via param
private readonly string $env, // kernel env
private readonly TranslatorInterface $translator,
) {
}
public function __invoke(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
// ignore preflight
if ($request->getMethod() === 'OPTIONS') {
return;
}
if (!$this->enabled) {
return;
}
$clientVersion = $request->headers->get('X-App-Version');
if (!$clientVersion) {
return;
} // pas de header => on laisse passer (ou durcir si tu veux)
if ($this->comparator->compare($clientVersion, $this->minVersion) < 0) {
$payload = ['error' => 'app_version_upgrade_required',
'message' => $this->translator->trans('version.app_version_upgrade_required'),
'min_required_version' => $this->minVersion,
'current_version' => $clientVersion,
// Optionnel: URLs de store pour un deep-link côté app
'stores' => [
'android' => 'https://play.google.com/store/apps/details?id=com.example.app',
'ios' => 'https://apps.apple.com/app/id1234567890',
],
];
$event->setResponse(new JsonResponse($payload, 426));
}
}
}
Enregistrement du service
Déclare le listener et le service pour que l'application fasse l'auto-wiring correctement. Il faut également déclarer les paramètres pour mapper avec les variables d'environnement.
parameters:
app.min_mobile_version: '%env(APP_MIN_MOBILE_VERSION)%'
app.force_update_enabled: '%env(bool:APP_FORCE_UPDATE_ENABLED)%'
services:
App\Version\VersionComparator: ~
App\Events\EventListener\Version\EnforceMobileVersionListener:
arguments:
$minVersion: '%app.min_mobile_version%'
$enabled: '%app.force_update_enabled%'
$env: '%kernel.environment%'
CORS (si API publique)
Autorise ton header custom dans config/packages/nelmio_cors.yaml :
nelmio_cors:
defaults:
allow_origin: ['*']
allow_headers: ['Content-Type', 'Authorization', 'X-App-Version']
allow_methods: ['GET','POST','PUT','PATCH','DELETE','OPTIONS']
Endpoint de test
Crée un petit endpoint /ping pour vérifier le comportement sans dépendances extérieures :
namespace App\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
final class PingController
{
#[Route('/ping', name: 'app_ping', methods: ['GET'])]
public function __invoke(): JsonResponse
{
return new JsonResponse(['ok' => true, 'ts' => time()]);
}
}
Tests fonctionnels
Crée un fichier tests/Functional/Version/EnforceMobileVersionTest.php :
<?php
namespace App\Tests\Functional\Version;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class EnforceMobileVersionTest extends WebTestCase
{
public function testPingOkWhenVersionAboveMin(): void
{
// min = 2.3.0 (voir .env.test)
$client = static::createClient();
$client->request('GET', '/ping', server: [
'HTTP_X_APP_VERSION' => '2.3.1',
]);
$this->assertResponseIsSuccessful(); // 200
$content = $client->getResponse()->getContent();
$this->assertIsString($content);
$json = json_decode($content, true);
$this->assertTrue($json['ok']);
}
public function testPingRequiresUpgradeWhenVersionBelowMin(): void
{
$client = static::createClient();
$client->request('GET', '/ping', server: [
'HTTP_X_APP_VERSION' => '2.2.9',
]);
$this->assertSame(426, $client->getResponse()->getStatusCode());
$content = $client->getResponse()->getContent();
$this->assertIsString($content);
$json = json_decode($content, true);
$this->assertSame('app_version_upgrade_required', $json['error']);
$this->assertSame('2.3.0', $json['min_required_version']); // conforme .env.test
$this->assertSame('2.2.9', $json['current_version']);
}
public function testPingPassesWhenHeaderMissing(): void
{
// Selon notre choix: pas de header => on laisse passer.
$client = static::createClient();
$client->request('GET', '/ping');
$this->assertResponseIsSuccessful(); // 200
}
}
Stratégie de versioning
| Type | Sens | Exemple | Blocage recommandé |
|---|---|---|---|
| Major | Rupture potentielle | 2.x → 3.x | Oui |
| Minor | Nouvelles features | 2.2 → 2.3 | Selon compatibilité |
| Patch | Corrections | 2.3.0 → 2.3.1 | Rarement sauf sécurité |
Les pré-releases sont ignorées (normalisation regex). Adapte si nécessaire.
Observabilité
Objectif: savoir quand forcer une mise à jour, mesurer l'adoption après changement de version minimale et détecter un éventuel problème côté client.
Métriques
| Metric | Type | Description | Exemple |
|---|---|---|---|
mobile_version_total | Counter | Requêtes avec header version | mobile_version_total{version="2.3.1"} |
mobile_version_reject_total | Counter | Rejets 426 | mobile_version_reject_total{version="2.2.0"} |
mobile_version_reject_ratio | Gauge (calc) | reject / total dernière 15 min | 0.07 |
On peut définir une alerte si mobile_version_reject_ratio > 0.1 juste après un déploiement pour être plus sensible si quelque chose casse.
Configuration monolog
Configuration dédiée:
monolog:
channels: ['mobile_version']
handlers:
mobile_version:
type: stream
path: '%kernel.project_dir%/var/log/mobile_version.log'
level: info
channels: ['mobile_version']
Injection dans le listener :
#[\Symfony\Component\DependencyInjection\Attribute\Autowire(service: 'monolog.logger.mobile_version')]
private readonly \Psr\Log\LoggerInterface $logger,
Log les rejets:
$this->logger->info('version.reject', [
'client_version' => $clientVersion,
'min_version' => $this->minVersion,
]);
Traces (optionnel si utilisation d'OpenTelemetry)
Si tu utilises OpenTelemetry: ajoute deux attributs sur la span request.
$span->setAttribute('app.mobile.version', $clientVersion);
$span->setAttribute('app.mobile.min_version', $this->minVersion);
Ne logge pas les succès: cela gonfle inutilement la taille disque et n'apporte pas de signal.
Curl rapide
- OK
- Obsolète
- Sans header
curl -i -H "X-App-Version: 2.3.5" https://api.example.com/ping | grep HTTP
curl -i -H "X-App-Version: 2.2.0" https://api.example.com/ping | grep HTTP
curl -i https://api.example.com/ping | grep HTTP
Checklist déploiement
| Étape | OK |
|---|---|
Paramètres .env | ✅ |
| Tests verts | ✅ |
| Monitoring / logs | ✅ |
| Communication mobile | ✅ |
| Valeur min publiée sur stores | ✅ |
FAQ
Pourquoi 426 ?
Il signale explicitement qu’une mise à niveau est nécessaire (RFC), plus clair que 400 ou 403.
Bloquer si header absent ?
Option possible mais risque de casser des clients existants; introduis-le plus tard si besoin.