Aller au contenu principal
Retour au blog

PHP 8.3 → 8.4 : corriger les nullable implicites sans casser la CI

Flavien Métivier7 janvier 20258 min

PHP 8.4 est sorti le 21 novembre 2024. Derrière les property hooks et l'asymmetric visibility se cache une déprécation qui va faire mal silencieusement dans des milliers de codebases Symfony : function foo(string $x = null) émet désormais un E_DEPRECATED. Ce pattern dit implicit nullable parameter est omniprésent — dans les setters générés par make:entity, dans les constructeurs de commandes Console, dans les event listeners écrits en 2019 et jamais retouchés depuis. Si tu fais tourner PHP 8.4 sans avoir adressé ces occurrences, tu vas noyer tes logs de dépréciations et préparer une erreur fatale pour PHP 9. Ce guide te montre comment détecter l'ensemble des cas automatiquement, les corriger par paliers avec Rector, et intégrer cette migration dans ta CI sans bloquer ton équipe.

Ce que PHP 8.4 déprécie exactement

Avant PHP 8.4, PHP inférait tacitement qu'un paramètre typé avec une valeur par défaut null était nullable — sans que tu aies à écrire le ?. Ce comportement implicite est maintenant signalé par un E_DEPRECATED, et il sera supprimé en PHP 9 où ce sera une erreur fatale. La correction est mécanique : soit on ajoute ? devant le type, soit on passe au union type string|null, soit on supprime la valeur par défaut si null n'est jamais passé intentionnellement.

<?php
// PHP 8.3 : silencieux
function process(string $data = null): void {}

// PHP 8.4 : E_DEPRECATED
// Deprecated: Implicitly marking parameter $data of function process() as nullable
// is deprecated, the explicit nullable type must be used instead in ...

// Correction 1 — nullable explicite (le plus courant)
function process(?string $data = null): void {}

// Correction 2 — union type (PHP 8.0+, équivalent strict)
function process(string|null $data = null): void {}

// Correction 3 — supprimer le default si null n'est jamais passé
function process(string $data): void {}

Cartographier l'impact avant de toucher quoi que ce soit

Avant de lancer Rector sur l'ensemble du projet, mesure l'amplitude réelle du problème. Un grep ciblé donne une première estimation en quelques secondes. La méthode plus fiable reste de faire tourner PHP 8.4 lui-même sur le code — le moteur liste toutes les dépréciations au chargement, sans exécution complète.

# Estimation rapide : chercher les patterns suspects
grep -rn "= null)" src/ --include="*.php" \
  | grep -v "\?string\|\?int\|\?array\|\?bool\|\?float\|\|null"

# Scan réel avec PHP 8.4 en Docker (sans polluer l'env local)
docker run --rm -v "$(pwd)/src:/app" php:8.4-cli \
  php -d error_reporting=E_ALL \
      -d display_errors=1 \
      -r "
  \$it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator('/app')
  );
  foreach (\$it as \$f) {
    if (\$f->getExtension() === 'php') {
      @include_once \$f->getPathname();
    }
  }
" 2>&1 | grep -i 'deprecated.*nullable'

PHPStan en éclaireur : rapporter sans modifier

PHPStan est l'outil de choix pour générer un inventaire précis avant toute modification. En ajoutant le paramètre phpVersion: 80400, tu forces PHPStan à analyser le code depuis la perspective de PHP 8.4 et à signaler les implicit nullable comme violations. Combine ça avec phpstan/phpstan-deprecation-rules pour couvrir aussi les usages de fonctions dépréciées au-delà des seuls paramètres.

docker compose run --rm php composer require --dev phpstan/phpstan-deprecation-rules
# phpstan.neon
includes:
    - vendor/phpstan/phpstan-deprecation-rules/rules.neon

parameters:
    level: 8
    phpVersion: 80400
    paths:
        - src
        - tests
# Génère le rapport et compte les nullable implicites
docker compose run --rm php \
  vendor/bin/phpstan analyse --error-format=json --no-progress \
  2>/dev/null | jq '[.files[].messages[] | select(.message | test("nullable"; "i"))] | length'
# -> 47 dans un projet Symfony 7.2 de taille moyenne, c'est normal

# Génère une baseline pour ne pas bloquer la CI sur l'existant
docker compose run --rm php \
  vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon

Rector : corriger 200 occurrences en une commande

Rector est l'outil de référence pour la migration automatisée de PHP. Depuis la version 1.4, le set PHP 8.4 inclut ExplicitNullableParamTypeRector, la règle dédiée à cette déprécation précise. Tu peux cibler uniquement cette règle pour un passage chirurgical, ou utiliser l'ensemble LevelSetList::UP_TO_PHP_84 si tu veux adresser tous les autres changements de la version en même temps. Pour une migration sans risque, commence par la règle seule.

<?php
// rector.php
declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Php84\Rector\Param\ExplicitNullableParamTypeRector;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ])
    ->withRules([
        ExplicitNullableParamTypeRector::class,
    ])
    ->withImportNames();
# Dry-run obligatoire : voir ce que Rector va faire sans modifier
docker compose run --rm php vendor/bin/rector process --dry-run

# Appliquer les corrections
docker compose run --rm php vendor/bin/rector process

# Vérifier que les tests passent
docker compose run --rm php vendor/bin/phpunit

# PHPStan repasse : le nombre d'erreurs nullable doit être à 0
docker compose run --rm php vendor/bin/phpstan analyse --no-progress

Besoin d'un expert Symfony ?

Réserver un appel

Stratégie CI : itérer par paliers sans bloquer l'équipe

Sur un projet de 50+ entités et 30+ commandes Symfony, tout corriger en un seul commit est risqué. La stratégie en paliers isole les zones de risque et permet à l'équipe de continuer à merger sans friction pendant la migration. L'enjeu n'est pas la vitesse d'exécution, mais la confiance dans chaque étape.

  • Palier 0 — générer la baseline PHPStan sur main, commiter phpstan-baseline.neon : la CI ne bloque pas sur l'existant
  • Palier 1 — lancer Rector uniquement sur src/Entity/, commiter avec les tests Doctrine
  • Palier 2 — étendre à src/Command/ et src/EventListener/
  • Palier 3 — couvrir src/Service/, src/Form/, src/Security/
  • Palier 4 — inclure tests/ et src/ complet, supprimer la baseline
  • Palier 5 — activer le fail PHPStan dans la CI sur toute nouvelle occurrence : la migration est verrouillée
# .github/workflows/php84-nullable.yml
name: PHP 8.4 Nullable Check

on: [push, pull_request]

jobs:
  nullable-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build PHP image
        run: docker compose build php

      - name: Install dependencies
        run: docker compose run --rm php composer install --no-interaction

      - name: PHPStan with PHP 8.4 target (baseline active)
        run: |
          docker compose run --rm php \
            vendor/bin/phpstan analyse \
            --no-progress \
            --error-format=table
        # La baseline absorbe l'existant. Toute NOUVELLE occurrence fait échouer le job.

      - name: Rector dry-run (aucun changement résiduel attendu)
        run: |
          docker compose run --rm php \
            vendor/bin/rector process --dry-run --no-progress
          # Échoue si Rector a encore des corrections à proposer

Le piège des entités Doctrine

Les entités Doctrine concentrent la majorité des occurrences. Le générateur make:entity de Symfony MakerBundle a longtemps produit des setters avec la signature setDescription(string $description = null). Rector corrige le setter automatiquement, mais attention : si la propriété elle-même est déclarée private string $description = null (type non-nullable, valeur null), Rector ne résoudra pas ce second problème — c'est un bug de typage distinct que PHPStan va signaler séparément.

<?php
// src/Entity/Product.php — AVANT (PHP 8.3, silencieux)
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Product
{
    #[ORM\Column(nullable: true)]
    private string $description = null; // propriete mal typee ET valeur null

    // Setter généré par make:entity
    public function setDescription(string $description = null): static
    {
        $this->description = $description;
        return $this;
    }
}

// src/Entity/Product.php — APRES Rector + correction manuelle propriete
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Product
{
    #[ORM\Column(nullable: true)]
    private ?string $description = null; // nullable explicite sur la propriete

    public function setDescription(?string $description = null): static
    {
        $this->description = $description;
        return $this;
    }
}

Après chaque passage Rector sur un répertoire Entity, relance PHPStan et filtre sur null dans les résultats. Les propriétés non-nullable initialisées à null sortent dans un type d'erreur différent de l'implicit nullable — corrige-les manuellement ou ajoute la règle TypedPropertyFromAssignsRector à ta config Rector.

Commandes Symfony et l'héritage de Command

Le deuxième terrain miné, c'est la classe Command de Symfony. La signature historique du constructeur est __construct(string $name = null) — exactement le pattern déprécié. Symfony 7.2, sorti en novembre 2024, a mis à jour son propre code vers ?string $name = null. Mais tes commandes custom qui redéfinissent ce constructeur sont probablement encore sur l'ancienne signature, et Rector les corrige automatiquement.

<?php
// src/Command/ImportUsersCommand.php — AVANT
namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class ImportUsersCommand extends Command
{
    public function __construct(
        private readonly UserImporter $importer,
        string $name = null  // PHP 8.4 : E_DEPRECATED
    ) {
        parent::__construct($name);
    }

    protected function configure(): void
    {
        $this->setName('app:import-users')
             ->setDescription('Importe les utilisateurs depuis le CSV');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        return Command::SUCCESS;
    }
}

// src/Command/ImportUsersCommand.php — APRES Rector
class ImportUsersCommand extends Command
{
    public function __construct(
        private readonly UserImporter $importer,
        ?string $name = null  // nullable explicite
    ) {
        parent::__construct($name);
    }
    // ...
}

La correction la plus propre reste de supprimer complètement le paramètre $name du constructeur de tes commandes. Depuis Symfony 6.4, le nom de commande se déclare dans configure() ou via l'attribut #[AsCommand(name: 'app:import-users')]. Passer le nom par constructeur est un pattern hérité de Symfony 3.x — autant en profiter pour nettoyer.

Conclusion

Migrer de PHP 8.3 vers 8.4 sans casser la CI est entièrement faisable en une semaine sur un projet Symfony de taille standard. Le workflow est reproductible : scan initial avec grep et PHP 8.4 en Docker, inventaire précis avec PHPStan à phpVersion: 80400, baseline pour ne pas bloquer l'équipe, correction automatisée avec ExplicitNullableParamTypeRector par paliers successifs, et verrouillage CI final. Les deux zones à inspecter manuellement après Rector sont les déclarations de propriétés Doctrine et les constructeurs de commandes Symfony. Les autres dépréciations PHP 8.4 — la plupart concernent des extensions peu utilisées — méritent un second passage Rector avec le set complet LevelSetList::UP_TO_PHP_84. Si tu veux démarrer la migration avec une cartographie exhaustive des risques sur ta codebase — compatibilité PHP 8.4, zones critiques Doctrine, coverage PHPStan, audit des dépendances — le Bear Scan livre un rapport actionnable en 48h, directement exploitable pour un chantier Bear Upgrade.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.