Aller au contenu principal

Bloquer les versions obsolètes - Symfony

remarque

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.

Code source complet

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.

Symfony : 1h

Pré-requis

Avant de mettre en place ce mécanisme:

  1. Définis une convention de version (SemVer simplifié major.minor.patch).
  2. Prépare la clé de traduction version.app_version_upgrade_required.
  3. Vérifie que X-App-Version traverse proxy/CDN/WAF.
  4. Décide du comportement si le header est absent (ici: permissif).
  5. Configure les environnements (.env.local, staging, prod, test).
Phase d’observation

Commence avec APP_FORCE_UPDATE_ENABLED=false en production pour mesurer la répartition des versions avant le blocage.

Traduction

translations/messages.fr.yaml
version:
app_version_upgrade_required: 'Une mise à jour est requise pour utiliser cette application.'
translations/messages.en.yaml
version:
app_version_upgrade_required: 'An upgrade is required to use this application.'

Variables par environnement

APP_MIN_MOBILE_VERSION=0.0.0
APP_FORCE_UPDATE_ENABLED=false
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 :

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

src/Service/Version/VersionComparator.php

<?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.

src/Events/EventListener/Version/EnforceMobileVersionListener.php

<?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.

config/services.yaml
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 :

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 :

src/Controller/PingController.php
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 :

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

TypeSensExempleBlocage recommandé
MajorRupture potentielle2.x → 3.xOui
MinorNouvelles features2.2 → 2.3Selon compatibilité
PatchCorrections2.3.0 → 2.3.1Rarement 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

MetricTypeDescriptionExemple
mobile_version_totalCounterRequêtes avec header versionmobile_version_total{version="2.3.1"}
mobile_version_reject_totalCounterRejets 426mobile_version_reject_total{version="2.2.0"}
mobile_version_reject_ratioGauge (calc)reject / total dernière 15 min0.07
Seuil de déclenchement

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:

config/packages/monolog.yaml
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);
Volume de logs

Ne logge pas les succès: cela gonfle inutilement la taille disque et n'apporte pas de signal.


Curl rapide

curl -i -H "X-App-Version: 2.3.5" https://api.example.com/ping | grep HTTP

Checklist déploiement

ÉtapeOK
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.