Migration de PHP 7.3 vers PHP 8.2

Electrodepot - Migration de PHP 7.3 vers PHP 8.2

Electrodepot - Migration de PHP 7.3 vers PHP 8.2

Mise en conformité PHP 8.2 de nos modules vendor

Le point de départ

Nos modules tournaient sur PHP 7.3, version en fin de vie depuis fin 2021. La cible retenue a été PHP 8.2, avec la montée du socle nginx associée. Le travail s'est réparti sur deux axes menés en parallèle : la reprise du code applicatif de notre côté, la montée de version des sources du côté OPS.

Le périmètre

Nous avons repris 8 modules vendor dont nous sommes propriétaires. C'est un point qui a simplifié le projet : nous en portons la responsabilité, donc nous avons maîtrisé le calendrier et le niveau d'exigence, sans dépendre du planning d'un éditeur tiers.

Cette propriété implique aussi une obligation : nous sommes l'éditeur de ces modules pour les projets qui les consomment. Nous avons donc resserré les contraintes Composer de chacun sur "php": "~8.2.0", plutôt que de laisser des contraintes permissives héritées qui auraient laissé Composer résoudre des combinaisons jamais testées.

        
  {
  "name": "acme/module-catalog",
  "require": {
    "php": ">=7.3"      // contrainte héritée, jamais vérifiée
    "php": "~8.2.0"    // la seule version que nous validons
  }
}
    

La reprise du code source

Nous avons repris le code source de chacun de ces modules pour le mettre en conformité avec les directives PHP 8.2. Voici les points qui nous ont demandé le plus d'interventions :

  • les propriétés dynamiques, dépréciées en 8.2 : déclaration explicite des propriétés, ou #[AllowDynamicProperties ] là où le comportement était voulu ;
        <?php
// Avant — Deprecated: Creation of dynamic property est déprécié
class Contexte
{
    public function hydrater(array $donnees): void
    {
        foreach ($donnees as $cle => $valeur) {
            $this->$cle = $valeur;
        }
    }
}

// Après — état déclaré, plus de propriété créée à la volée
class Contexte
{
    /** @var array<string, scalar> */
    private array $attributs = [];

    public function hydrater(array $donnees): void
    {
        $this->attributs = $donnees;
    }

    public function get(string $cle): mixed
    {
        return $this->attributs[$cle] ?? null;
    }
}
    
  • l'interpolation de chaînes en ${var}, dépréciée, remplacée par {$var } ;
  • les comparaisons lâches entre chaînes et nombres, dont la sémantique a changé depuis PHP 8.0 : typage explicite avant comparaison plutôt que substitution mécanique de == par === ;
        <?php
// Le piège : $code vient d'une requête, c'est toujours une chaîne.
// PHP 7 : "REMISE10" == 0 valait true. PHP 8 : false.
if ($code == 0) {
    $this->reinitialiser();
}

// Après : on normalise, puis on compare strictement.
$code = trim((string) $code);

if ($code === '') {
    $this->reinitialiser();
}
    
  • les signatures et le typage : retours déclarés, nullabilité explicite, méthodes magiques conformes ;
  • les avertissements devenus erreurs depuis PHP 8.0, notamment le passage de null à des paramètres de fonctions internes non nullables, qui se traduisait chez nous par des dépréciations en masse dans les logs ;
  • les appels à des fonctions supprimées ou dépréciées, dont utf8_encode et utf8_decode ;
  • les arguments nommés, disponibles depuis PHP 8.0, que nous avons adoptés sur les signatures à paramètres multiples et optionnels.

C'est la famille qui a demandé le plus de vigilance : en PHP 7 ces cas produisaient un avertissement et une valeur de repli, donc un résultat faux mais silencieux. En PHP 8 ils lèvent une exception, donc une erreur 500 franche.

        <?php
// PHP 7 : Warning + résultat INF, le calcul se poursuivait
// PHP 8 : DivisionByZeroError, exception fatale
$taux = $montantRemise / $montantTotal;

$taux = $montantTotal > 0.0 ? $montantRemise / $montantTotal : 0.0;

// PHP 7 : Warning + retour false, la boucle tournait à vide
// PHP 8 : ValueError si $taille vaut 0
foreach (array_chunk($references, $taille) as $lot) {

$taille = max(1, (int) $taille);
foreach (array_chunk($references, $taille) as $lot) {

// $sku vaut null quand l'attribut n'est pas chargé
// PHP 8.1+ : Deprecated: strlen(): Passing null to parameter #1
if (strlen($sku) > 0) {
if ((string) $sku !== '') {
    
        <?php
// Interpolation ${} dépréciée en 8.2
$chemin = "${racine}/export/${fichier}.csv";
$chemin = "{$racine}/export/{$fichier}.csv";

// utf8_encode / utf8_decode dépréciées : encodage source explicite
$libelle = utf8_encode($ligneCsv[2]);
$libelle = mb_convert_encoding($ligneCsv[2], 'UTF-8', 'ISO-8859-1');
    

Les arguments nommés ont été le seul apport de PHP 8 que nous avons introduit volontairement, et non par contrainte. Ils suppriment les appels illisibles à cascade de booléens et permettent de ne renseigner que les paramètres utiles :

        <?php
public function exporter(
    string $chemin,
    string $separateur = ';',
    bool $entetes = true,
    bool $forcerGuillemets = false,
    ?string $encodage = null,
): int {
    // ...
}

// Avant : impossible de savoir ce que valent ces booléens sans ouvrir la méthode
$this->exporter($chemin, ';', true, false, 'UTF-8');

// Après : on ne nomme que ce qui compte, l'ordre n'a plus d'importance
$this->exporter($chemin, encodage: 'UTF-8');
$this->exporter($chemin, forcerGuillemets: true, entetes: false);
    

Cela a une conséquence que nous avons prise en compte, puisque nous sommes l'éditeur de ces modules : le nom d'un paramètre devient du contrat public. Le renommer casse les appels des projets consommateurs. Nous avons donc figé les noms de paramètres de nos méthodes publiques et inscrit leur modification comme un changement majeur au sens du versionnage sémantique.

Nous avons traité un module à la fois, sur une branche dédiée, avec un commit par famille de règle. C'était moins rapide qu'une passe globale, mais cela a rendu les revues de code réellement lisibles et permis de livrer les premiers modules sans attendre les derniers.

La demande OPS

En parallèle, nous avons formulé auprès de l'équipe OPS la demande de montée de version des sources :

Composant Avant Après
nginx 1.16 1.24
PHP 7.3 8.2

Les deux montées ont été demandées ensemble, de façon à disposer d'un environnement cible cohérent pour la recette plutôt que de valider le code sur un socle intermédiaire. La montée nginx a été l'occasion d'une relecture de la configuration : directives obsolètes, en-têtes et paramètres de fastcgi_pass vers le nouveau socket PHP-FPM.

Nous avons demandé que la même version soit appliquée sur les environnements de développement et d'intégration avant la production, pour valider le code sur un socle identique à la cible et non sur une approximation locale.

La validation

Nous n'avons pas considéré la conformité comme acquise sur la seule absence d'erreur au démarrage. Chaque module a été validé par trois moyens complémentaires :

  • les tests unitaires, exécutés sur le socle PHP 8.2, pour vérifier que le comportement fonctionnel est inchangé — c'est le seul filet qui détecte les régressions silencieuses liées aux comparaisons et aux conversions de types ;
  • > PHPStan, pour l'analyse statique de typage et la détection des incompatibilités que l'exécution ne révèle pas sur les chemins non couverts ;
  • > Sonar, pour la qualité et le suivi de la dette dans le temps, avec un seuil de qualité en barrage sur la chaîne d'intégration.
Contrôle Ce qu'il détecte Statut CI
Tests unitaires Régressions de comportement sur les chemins couverts Bloquant
PHPStan Typage, nullabilité, incompatibilités hors couverture Bloquant
Sonar Qualité, duplication, suivi de dette dans le temps Seuil en barrage
        validation:
  image: php:8.2-cli
  script:
    - composer install --no-interaction --prefer-dist
    - vendor/bin/phpstan analyse --no-progress
    - vendor/bin/phpunit --coverage-clover coverage.xml
    - sonar-scanner -Dsonar.php.coverage.reportPaths=coverage.xml
  # aucun allow_failure : les trois portes sont bloquantes
    

Nous avons pris les contrôles dans cet ordre : d'abord PHPStan pour purger les erreurs de typage en masse, puis les tests unitaires pour vérifier que les corrections n'avaient rien changé au comportement, puis Sonar pour arbitrer ce qui restait. Les trois sont passés en bloquants une fois les modules au vert, ce qui garantit qu'aucune régression de compatibilité ne pourra être réintroduite.

Où nous en sommes

Les 8 modules sont conformes PHP 8.2, validés par les tests unitaires, PHPStan et Sonar, sur un socle nginx 1.24 / PHP 8.2. La chaîne de validation reste en place pour les évolutions suivantes.

Le bénéfice dépasse la seule conformité : nous sortons de ce chantier avec de vraies contraintes de version, une couverture de tests sur les parties sensibles et une quality gate automatisée. La prochaine montée de version se fera à coût marginal, et c'est le résultat qui nous importait le plus.