Qualité de code PHP au quotidien : entretien éditorial avec un lead développeur Symfony

La qualité PHP se construit par paliers : typage strict, tests de caractérisation, PHPStan ou Psalm avec une baseline, Rector sous contrôle, puis vérifications CI ciblées. La code review traite les décisions métier ; l'IA prépare des corrections, mais ne décide ni de leur pertinence ni du risque acceptable. Entretien éditorial avec un lead développeur Symfony habitué aux applications existantes.

La qualité PHP se construit par paliers : typage strict, tests de caractérisation, PHPStan ou Psalm avec une baseline, Rector sous contrôle, puis vérifications CI ciblées. La code review traite les décisions métier ; l’IA prépare des corrections, mais ne décide ni de leur pertinence ni du risque acceptable. Entretien éditorial avec un lead développeur Symfony habitué aux applications existantes.

Lead développeuse Symfony dans un bureau lumineux avec un ordinateur portable affichant du code annoté d'analyse statique
Entretien éditorial : la qualité de code PHP se construit par paliers plutôt que par des règles imposées d'un coup.

Préparer le terrain avant d’installer PHPStan ou Psalm

Par quoi commencer dans une équipe qui possède déjà plusieurs années de code legacy ?

Je ne commence pas par activer toutes les règles. Je vérifie d’abord si le code fournit suffisamment d’informations aux outils : types de paramètres, types de retour, propriétés typées et usage cohérent de null. Le manuel officiel de PHP sur les arguments rappelle le fonctionnement du typage des paramètres et de declare(strict_types=1). Sans ces fondations, l’analyse statique produit davantage de diagnostics ambigus.

<?php

declare(strict_types=1);

final readonly class Invoice
{
    public function __construct(
        public string $number,
        public int $amountInCents,
        public ?\DateTimeImmutable $paidAt,
    ) {
    }

    public function isPaid(): bool
    {
        return $this->paidAt !== null;
    }
}

Sur un ancien projet, je procède généralement ainsi :

  1. activer strict_types sur les nouveaux fichiers, sans réécrire immédiatement toute l’application ;
  2. typer les services situés aux frontières : contrôleurs, commandes, messages Messenger et clients HTTP ;
  3. lancer PHPStan ou Psalm sans bloquer la CI ;
  4. créer une baseline pour figer les problèmes existants ;
  5. interdire toute nouvelle erreur dans les fichiers modifiés.

La baseline n’est pas un certificat de conformité. C’est une photographie de la dette. Par exemple, si un repository retourne tantôt une entité, tantôt false, je ne masque pas éternellement le problème : j’introduis progressivement un retour nullable explicite.

public function findCustomer(int $id): ?Customer
{
    $customer = $this->entityManager->find(Customer::class, $id);

    return $customer instanceof Customer ? $customer : null;
}

PHPStan et Psalm couvrent un terrain proche. Dans une équipe, mieux vaut cependant choisir un outil principal plutôt que maintenir deux configurations divergentes. PHPStan s’intègre très naturellement à Symfony ; Psalm peut être pertinent lorsqu’un projet l’utilise déjà ou dépend fortement de ses annotations. Pour replacer cette démarche dans un socle moderne, notre guide Symfony complet détaille aussi l’organisation des services et des couches applicatives.

Monter progressivement le niveau de strictness

Faut-il viser immédiatement le niveau maximal de PHPStan ?

Non. La documentation officielle PHPStan — Rule Levels définit dix niveaux, de 0 à 9, avec max comme niveau suivant automatiquement les contrôles les plus stricts disponibles. Cette progression permet d’adopter l’outil sans transformer une première exécution en mur de milliers d’erreurs.

Voici une stratégie réaliste :

État du projet Niveau de départ Politique recommandée
Legacy peu typé 0 ou 1 Baseline globale, zéro nouvelle erreur
Application maintenue et testée 3 à 5 Correction par module ou répertoire
Nouveau service Symfony 6 à 8 Contrôle bloquant dès la pull request
Bibliothèque fortement typée 9 ou max Exceptions documentées individuellement

Je fais monter le niveau répertoire par répertoire. Une équipe peut conserver l’application entière au niveau 3 tout en analysant src/Billing au niveau 7. Le module nouvellement refactoré ne régresse donc pas pendant que le reste avance à son rythme.

Un cas fréquent concerne les tableaux associatifs issus d’une API. Le type array ne dit presque rien ; une forme documentée permet à PHPStan de détecter une clé manquante avant l’exécution.

/**
 * @param array{id: int, email: non-empty-string} $payload
 */
public function createCustomer(array $payload): Customer
{
    return new Customer($payload['id'], $payload['email']);
}

Je préfère néanmoins un DTO lorsque cette donnée traverse plusieurs couches. C’est plus lisible pour l’équipe, pour l’IDE et pour les assistants IA.

final readonly class CreateCustomerInput
{
    public function __construct(
        public int $id,
        public string $email,
    ) {
    }
}

Les niveaux et règles évoluent avec les versions majeures. Il faut donc vérifier la version installée avant de copier une configuration trouvée en ligne. Cette discipline rejoint les principes présentés dans le PHP moderne et la conception objet.

Écran d'ordinateur affichant une interface d'analyse statique de code avec des erreurs surlignées
Faire monter le niveau de strictness PHPStan répertoire par répertoire évite de transformer la première exécution en mur d'erreurs.

Utiliser Rector sans déléguer la migration à l’aveugle

Comment Rector facilite-t-il une montée de PHP 8.1 vers PHP 8.3 ou une migration Symfony ?

Rector traite les transformations mécaniques : syntaxe devenue obsolète, API dépréciées, propriétés typées ou règles propres à une version de framework. D’après la documentation officielle de Rector, l’outil s’appuie sur des ensembles de règles prédéfinis plutôt que sur des remplacements par expressions régulières. Selon la version installée, on rencontre notamment des configurations PHP 8.3 et des SymfonySetList.

Une configuration récente peut cibler uniquement le code applicatif et le niveau PHP voulu :

<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ])
    ->withPhpSets(php83: true);

Je lance d’abord Rector en mode aperçu, puis je découpe le résultat par familles de modifications. Une pull request qui mélange syntaxe PHP, changement Doctrine et logique métier est presque impossible à relire. Sur plusieurs milliers de fichiers, l’automatisation reste utile, mais la revue humaine doit se concentrer sur les cas ambigus : sérialisation, comparaison faible, dates, événements Symfony ou comportement d’une dépendance.

Avant une transformation sensible, j’ajoute un test de caractérisation. La documentation Testing de Symfony recommande PHPUnit comme base de test. Le test fixe le comportement observable, même si l’implémentation legacy n’est pas encore élégante.

public function testLegacyDiscountRemainsUnchanged(): void
{
    $calculator = self::getContainer()->get(DiscountCalculator::class);

    self::assertSame(
        8500,
        $calculator->calculateInCents(customerType: 'partner', total: 10000)
    );
}

Mon enchaînement est simple : tests verts, aperçu Rector, commit mécanique, nouvelle exécution des tests, puis analyse statique. Pour une migration complète du framework, le guide de migration Symfony 6 vers 7 complète utilement cette méthode.

Intégrer la qualité dans la CI sans arrêter les livraisons

Comment rendre PHPStan, les tests et l’audit bloquants sans paralyser l’équipe ?

Je sépare l’existant du nouveau. La CI échoue si une pull request introduit une erreur supplémentaire, pas parce qu’une classe non touchée contient une anomalie connue depuis cinq ans. Une baseline phpstan-baseline.neon matérialise cette frontière, à condition d’être suivie : sa taille doit diminuer et toute nouvelle exclusion doit être justifiée.

Le pipeline minimal exécute :

  • les tests unitaires rapides ;
  • PHPStan ou Psalm sur le périmètre prévu ;
  • la vérification du style ;
  • composer audit pour contrôler les vulnérabilités connues des dépendances ;
  • les tests d’intégration lorsque la modification touche Doctrine, Messenger ou une API.

La documentation Composer — Scripts explique comment déclarer des commandes personnalisées et utiliser les événements du cycle de vie. L’équipe peut ainsi exposer une commande commune, par exemple composer qa, appelée à l’identique sur un poste local et dans GitHub Actions ou GitLab CI. Composer fournit également la commande composer audit.

Pour éviter les faux succès, je teste aussi les branches d’erreur. Un service Symfony qui appelle un transport externe doit avoir un comportement explicite face à une réponse invalide.

public function send(Order $order): void
{
    try {
        $this->gateway->charge($order->totalInCents());
    } catch (GatewayUnavailable $exception) {
        throw new PaymentTemporarilyUnavailable(
            previous: $exception
        );
    }
}

Premier cas concret : un changement de commentaire ne justifie pas vingt minutes de tests fonctionnels. Deuxième cas : une migration Doctrine doit exécuter les tests liés à la persistance, même si seulement deux fichiers PHP changent. Les contrôles peuvent donc être rapides sur chaque commit et complets avant fusion. Notre guide sur la CI/CD Symfony avec GitLab et GitHub Actions montre comment matérialiser ces étapes.

Deux développeurs adultes en réunion de revue de code autour d'un écran partagé affichant une pull request
Une code review efficace se concentre sur le comportement métier et les risques, pas sur ce que les outils automatisés vérifient déjà.

Faire une code review courte, ciblée et utile

Une checklist courte est-elle réellement préférable à une revue exhaustive ?

Oui, parce qu’une revue qui prétend tout vérifier finit souvent par ne rien vérifier correctement. PHPStan repère les incohérences de types ; Rector traite les transformations répétitives ; le formateur contrôle le style. Le reviewer doit réserver son attention au comportement, aux frontières architecturales et aux risques.

Ma checklist tient en cinq questions :

  1. le changement répond-il au besoin sans modifier un comportement voisin ?
  2. les noms expriment-ils le vocabulaire métier ?
  3. les entrées externes sont-elles validées ?
  4. les erreurs et autorisations sont-elles traitées explicitement ?
  5. les tests couvrent-ils le scénario nominal et l’échec significatif ?

Prenons un contrôleur qui transmet directement $request->request->all() à un service. Il peut satisfaire l’analyse statique avec une annotation très large, tout en restant fragile. La bonne revue demande plutôt un objet d’entrée validé.

final readonly class RegisterUserInput
{
    public function __construct(
        #[Assert\Email]
        public string $email,

        #[Assert\Length(min: 12)]
        public string $password,
    ) {
    }
}

Autre cas : une requête Doctrine ajoutée dans une boucle peut être correctement typée et pourtant provoquer une avalanche d’accès SQL. La revue architecturale reste indispensable. À l’inverse, discuter pendant quinze commentaires de l’ordre de deux méthodes détourne l’attention d’une autorisation manquante.

Je demande enfin des pull requests étroites : un refactoring mécanique, puis le changement fonctionnel. Pour approfondir la dimension sécurité de cette lecture, consultez notre dossier sur les failles Symfony à identifier en entretien et en architecture.

Gérer la dette et encadrer Copilot ou Claude Code

Comment concilier deadlines, dette technique et assistants IA sans perdre le jugement humain ?

Je traite la dette comme un risque localisé, pas comme une grande opération abstraite. Chaque anomalie découverte reçoit l’une de ces décisions : corriger maintenant, inscrire une tâche avec contexte, ou accepter explicitement le risque. Une méthode de paiement sans tests mérite une priorité différente d’un nom de variable maladroit dans une commande interne.

Sous pression, j’applique la règle du périmètre touché : le nouveau code respecte le standard actuel, et l’ancien code adjacent est amélioré seulement si cela sécurise la livraison. Par exemple, avant d’ajouter un troisième cas à un switch métier fragile, j’écris un test de caractérisation. En revanche, je ne réorganise pas tout le module pendant un correctif de production.

Copilot et Claude Code peuvent accélérer plusieurs tâches :

  • expliquer un diagnostic PHPStan dans le contexte d’un type générique ;
  • proposer des tests manquants autour d’un service Symfony ;
  • résumer un diff Rector volumineux ;
  • repérer des branches non couvertes ou une gestion incohérente de null ;
  • préparer une petite correction soumise ensuite aux mêmes contrôles CI.

Je leur fournis des contraintes vérifiables : version de PHP, version de Symfony, signature publique à conserver et tests à ne pas modifier. Une proposition de test reste à examiner, car l’IA peut simplement reproduire l’implémentation au lieu de vérifier le besoin métier.

public function testCannotRefundAnAlreadyRefundedPayment(): void
{
    $payment = Payment::alreadyRefunded();

    $this->expectException(PaymentAlreadyRefunded::class);

    $payment->refund();
}

L’assistant peut écrire ce squelette. Seule l’équipe sait toutefois si un second remboursement doit lever une exception, être idempotent ou publier un événement. C’est pourquoi je refuse les corrections IA fusionnées sans lecture du diff, tests verts et analyse statique.

Le bon usage n’est donc pas « génère-moi une architecture », mais « examine ce changement précis, liste les risques et propose un test par hypothèse ». Notre comparatif Copilot, Cursor et Claude Code et le dossier sur le prompt engineering pour PHP et Symfony prolongent cette approche : l’IA augmente la capacité de vérification, sans remplacer la responsabilité du développeur.

Questions fréquentes

PHPStan ou Psalm : lequel choisir pour un projet Symfony ?
PHPStan s'intègre très naturellement à Symfony et couvre un terrain proche de Psalm. Mieux vaut choisir un outil principal plutôt que maintenir deux configurations divergentes dans une équipe. Psalm peut être pertinent si un projet l'utilise déjà ou dépend fortement de ses annotations spécifiques.
Faut-il viser immédiatement le niveau maximal de PHPStan ?
Non. PHPStan propose dix niveaux progressifs (0 à 9, puis max). Sur un legacy peu typé, on démarre au niveau 0 ou 1 avec une baseline globale et une politique de zéro nouvelle erreur, puis on fait monter le niveau répertoire par répertoire selon la maturité de chaque module.
Comment intégrer la qualité de code en CI sans bloquer les livraisons ?
En séparant l'existant du nouveau code. La CI échoue si une pull request introduit une nouvelle erreur, pas parce qu'une classe non touchée contient une anomalie connue depuis des années. Une baseline PHPStan matérialise cette frontière, à condition que sa taille diminue dans le temps et que toute nouvelle exclusion soit justifiée.
Une checklist de code review courte est-elle suffisante ?
Oui, car une revue qui prétend tout vérifier finit souvent par ne rien vérifier correctement. Les outils automatisés (PHPStan, Rector, formateur) couvrent les incohérences de types et le style. Le reviewer humain doit réserver son attention au comportement métier, aux frontières architecturales et à la gestion des erreurs et autorisations.
Peut-on faire confiance à Copilot ou Claude Code pour corriger du code legacy ?
Ces assistants accélèrent des tâches précises : expliquer un diagnostic PHPStan, proposer des tests manquants, résumer un diff Rector volumineux. Mais une proposition de test généré par l'IA doit toujours être examinée, car l'assistant peut reproduire l'implémentation existante au lieu de vérifier le besoin métier réel. Le diff, les tests et l'analyse statique restent obligatoires avant fusion.