Trois jours, cinq sessions accompagnée de Claude, un projet qui traînait du PHP 7.2 et du Symfony 4 comme un vieux pull qu’on n’ose plus jeter. Voici comment BioPHP, ma librairie de bioinformatique, s’est retrouvée propulsée jusqu’à PHP 8.5 et Symfony 7 — avec au passage quelques bugs qui n’attendaient qu’un changement de contexte pour sortir du bois.
Jour 1 : le grand dépoussiérage des dépendances
Tout commence par un composer install --dry-run qui recrache six blocages d’un coup : PHPUnit 7.5 has-been, twig 2.5 signalé par l’audit sécurité, guzzle 6.x dans le même sac, un doctrine/rst-parser cassé, et un conflit entre phpdocumentor et twig. Rien d’insurmontable, mais rien qui se règle non plus en tournant les yeux ailleurs.
Première décision : monter PHPUnit en 8.x, twig en 3.17, et sortir purement et simplement phpdocumentor du composer.json — aucune version stable ne réconciliait PHP 7.2 et twig 3, et le paquet n’était de toute façon utilisé nulle part dans le code.
Une fois composer install vert, la suite de tests, elle, ne l’était pas : setUp() incompatible avec la nouvelle signature de PHPUnit 8 (: void), à corriger sur 16 fichiers d’un coup. Puis, sur les 121 tests restants, trois problèmes bien réels apparaissent :
AminoApiTestappelait la vraie API en ligne, dont la réponse avait changé entre-temps (une clé manquante).PKApiTestavait sa seule méthode de test… commentée.testFindZScorecomparait des flottants calculés à des valeurs attendues codées en dur avec moins de décimales.
Les deux derniers ont attendu leur tour (ils reviendront), mais AminoApiTest a eu droit à un vrai traitement : garde isset() côté production pour ne plus planter quand l’API omet légitimement un champ (codon STOP, codes ambigus), poids résiduels biologiquement corrects ajoutés à la fixture, et surtout un MockHandler Guzzle pour ne plus dépendre du réseau à chaque run de test.
Dernier chantier du jour : le ménage des paquets abandoned. sensio/framework-extra-bundle disparaît (zéro usage réel), doctrine/annotations devient une dépendance explicite plutôt que tirée en douce par sensio. csa/guzzle-bundle, lui, reste — actif dans 15 classes Api/*, le remplacer aurait cassé l’API publique de Bioapi.php sans bénéfice immédiat.
121 tests / 252 assertions à la fin de cette première manche. Rien de committé — tout reste dans l’arbre de travail, en attente de validation.
Jour 1, suite : Symfony 5, puis 6, puis le grand ménage Guzzle
La même journée continue avec la montée vers Symfony 5.4 (LTS). Et c’est là qu’un détail technique révèle un vrai problème : --ignore-platform-req=php, utilisé depuis le début pour contourner le PHP 8.5 local, désactivait en réalité la vérification de compatibilité PHP partout dans la résolution des dépendances, pas seulement à la racine. Remplacé par une simulation honnête (config.platform.php = 7.2.5), la vérité éclate : sous contrainte PHP 7.2 réelle, aucune version de twig ne concilie compatibilité et correctif de sécurité critique. Même verdict pour guzzlehttp/psr7, plafonné par csa/guzzle-bundle.
Plutôt que d’accepter le risque, direction la sortie de secours : csa/guzzle-bundle est retiré, guzzlehttp/guzzle requis en direct, le client bioapi reconstruit à la main en XML de service. Bonus inattendu : twig redevient un simple outil de dev, sortant tout risque sécurité du périmètre de production. Les 15 classes Api/* n’ont même pas eu besoin de changer — l’API fluide de Guzzle 7 est restée identique à celle de Guzzle 6.
Le PHP local (8.5) satisfaisant désormais réellement ^8.1, la simulation de plateforme devient inutile. composer.json vise PHP 8.1, Symfony passe en ^6.4, composer audit tombe à zéro alerte de sécurité.
Reste un dernier paquet abandonné coriace : doctrine/annotations. Sa disparition profite d’un heureux hasard — PHP 8.1 permet les attributs natifs, et les 11 entités Doctrine du projet peuvent être converties d’un coup, des docblocks @ORM\... vers les attributs #[ORM\...]. C’est en écrivant un script de contrôle pour forcer le vrai chargement des métadonnées (aucun test existant ne le faisait, tous mockaient EntityManager) qu’un bug dormant remonte : Sequence::$primAcc empilait six annotations de relation contradictoires sur une seule propriété scalaire, dont une pointant vers une classe ScForm qui n’a jamais existé — une faute de frappe pour SrcForm, jamais détectée parce que les docblocks toléraient ce genre d’empilement silencieusement. Les attributs PHP, eux, refusent net (« Attribute must not be repeated »). Comme rien dans le code n’utilisait primAcc comme une vraie relation, les six attributs cassés sont retirés sans regret.
Jour 2 : valider en profondeur, puis viser Symfony 7
Nouvelle session, nouvelle question de cadrage : la cible Symfony 6.4 était déjà en place, mais est-ce que ça marche vraiment ? Les tests mockent EntityManager et Guzzle, ils n’exercent jamais le vrai câblage Symfony. Direction : faire booter un vrai conteneur.
Bien m’en a pris. Premier bug réel : getAlias() sans type de retour : string, requis par la classe de base sous Symfony 6.4 — une erreur fatale au chargement du bundle, invisible pour tout test existant jusque-là. Un test permanent est écrit pour ne plus jamais perdre ce genre de chose : il fait tourner le vrai MergeExtensionConfigurationPass, instancie une vraie AminoApi depuis le conteneur, vérifie que les 11 entités chargent leurs métadonnées via le pont réel Symfony/Doctrine.
Fort de cette base solide, cap sur Symfony 7 : PHP passe à ^8.2, toute la famille Symfony à ^7.4, et surtout Doctrine ORM passe en 3.6 — ce qui élimine automatiquement le dernier paquet abandonné, doctrine/cache. Ce même test permanent attrape immédiatement un second bug : getConfigTreeBuilder() sans type de retour, requis par Symfony 7.
PHPUnit doit suivre le mouvement jusqu’en 11.5 (Doctrine ORM 3.6 utilise des types union avec enum que le générateur de mocks de PHPUnit 8.5 ne sait pas reproduire), ce qui impose de migrer 16 appels setMethods() → onlyMethods() et 19 appels ->will($this->returnValue()) → ->willReturn().
Et puis, enfin, les deux problèmes préexistants depuis le premier jour trouvent leur solution. PKApiTest n’était pas juste désactivé par prudence : il était cassé dès l’origine, un cast d’objet à propriétés privées produisant des clés PHP « mangled » impossibles à comparer. Réécrit avec un vrai mock HTTP. Et testFindZScore : les valeurs attendues étaient simplement tronquées à la saisie, corrigées par régénération en pleine précision flottante.
123 tests / 262 assertions, 0 échec, 0 erreur. Les deux bugs les plus anciens du projet, enfin clos.
Jour 2, suite : la chasse aux dépréciations (et trois vrais bugs en prime)
Passer tous ces caps ne veut pas dire que tout est propre : 93 dépréciations PHP traînaient encore, toutes du même type — des paramètres string $x = null sans le ? explicite, dépréciées depuis PHP 8.4. Trois interfaces et quatre implémentations corrigées en miroir, une recherche exhaustive confirme qu’il n’en reste aucune.
Une fois ce ménage fait, 33 nouvelles dépréciations apparaissent (propriétés dynamiques non déclarées, dépréciées depuis PHP 8.2) — neuf fichiers de tests à corriger, et un faux positif écarté après vérification manuelle (un bug de portabilité sed/grep sur macOS dans le script de détection, pas un vrai problème).
Résultat : 0 dépréciation. Mais le plus intéressant de cette journée, ce sont les trois bugs signalés en relisant le code, indépendamment de l’audit :
RestrictionEnzymeManager::fetchCutposAndPlen()accumulait ses résultats dans une variable inexistante,$RestEn_List[], au lieu de$aEnzymes— la méthode réelle. Résultat : un tableau toujours vide, quels que soient les enzymes correspondants. Le test associé attendait lui-même un tableau vide, ce qui masquait le bug depuis sa création.SequenceManager::getBridge(), marqué d’un@todoinquiet (« Correct it – does not seems to work :/ ») sans test dédié. Après calcul manuel, test empirique exhaustif et comparaison ligne à ligne avec le Legacy : verdict, pas de bug — juste du doute jamais vérifié. Trois tests ajoutés, le@todoobsolète retiré.SequenceManager::complement(): la table de correspondance ne couvrait que les 4 bases canoniques. Tout code d’ambiguïté IUPAC (N, Y, R, W, S, K, M, D, V, H, B) provoquait un accès à une clé absente, et le caractère disparaissait silencieusement du résultat —complement("ACGTN")renvoyait 4 caractères au lieu de 5, avec décalage de position en aval. Une table de repli IUPAC règle le problème, et un symbole réellement inconnu lève désormais une exception explicite plutôt que de tronquer en silence.
129 tests / 268 assertions.
Jour 2, fin : CI, merge de conflits, et un warning qui trompe tout le monde
Dernier acte du jour : Travis CI ne tournait plus depuis longtemps (PHP 7.2 sur Ubuntu Trusty, toutes deux retirées du support). Migration vers GitHub Actions, matrice PHP 8.2 à 8.5, coverage uploadé vers Codecov sans token en clair cette fois.
Puis vient le merge develop → master, avec des conflits d’abord perçus comme injustifiés — jusqu’à vérification : bien réels, master ayant continué de vivre de son côté sur les mêmes entités que develop modernisait. Premier réflexe, git checkout --theirs sur les 10 fichiers en conflit : mauvaise idée, ça écrase aussi des hunks non conflictuels que git avait déjà fusionnés proprement, cassant 9 tests fraîchement arrivés. Les neuf entités sont reconstruites une par une, en combinant à la main les attributs ORM de develop et le pattern défensif de master (valeurs par défaut, types nullables).
Cerise sur le gâteau : après le merge, les jobs GitHub Actions passaient à 122 tests réussis… mais échouaient quand même. Cause : PHPUnit 11 fixe failOnPhpunitWarning à true par défaut, et le seul warning émis — « No code coverage driver available » — suffisait à faire échouer le process sur tous les jobs sans xdebug. Un simple failOnPhpunitWarning="false" dans phpunit.xml, et tout redevient cohérent.
140 tests / 324 assertions, 0 échec.
Jour 3 : ressusciter les parseurs Legacy
Le dossier Legacy/ de BioPHP portait à l’origine 20 formats de bases biologiques différentes. Un audit complet du 12 août avait révélé que la refonte n’en couvrait que 2 — GenBank et Swissprot — sur les 11 223 lignes de code historique, environ 61 % restaient purement absents.
Cette session traite la « Priorité 2 » de la feuille de route : quatre nouveaux parseurs, chacun suivant le schéma déjà posé par ParseGenbankManager — EMBL, dont le stub Legacy s’arrêtait en réalité au premier // sans jamais implémenter la moitié de ses propres champs déclarés ; PDB, où un portage ligne à ligne de la classe monolithique de 1845 lignes aurait été disproportionné, donc ciblé sur l’essentiel (séquence, structure secondaire, coordonnées atomiques) ; PROSITE, dont le Legacy contenait un vrai bug de variable jamais définie (heureusement du code mort) ; et ExPASy ENZYME, porté fidèlement, nomenclature EC à ne pas confondre avec les enzymes de restriction déjà couvertes par ailleurs.
Le parseur PDB révèle au passage un nouveau bug de régression : DatabaseManager::line2r() ne reconnaissait que // comme terminateur d’entrée — la convention GenBank/EMBL/Swissprot. Un fichier PDB, qui se termine par END, provoquait une boucle infinie jusqu’à épuisement mémoire. Corrigé, avec en prime une garde de fin de fichier qui manquait entièrement et protège désormais tous les formats contre un fichier tronqué.
Petit débat d’architecture au passage : les nouveaux objets structurés pour PDB, PROSITE et ExPASy (atomes, hélices, feuillets, références croisées) avaient d’abord été nommés *DTO et rangés sous Domain/. Rejeté après revue — dans ce projet, Domain/ ne connaît que des entités, et « DTO » est réservé à la frontière HTTP. Nouvelle convention actée : un dossier neutre Domain/Model/ pour tout ce qui est structuré sans être persisté ni exposé en API.
170 tests / 496 assertions pour clore ces trois jours.
Je peux lancer avec quelques réserves toutefois, 7 ans après le début de son développement, le lancement de la version 1.
Ce qu’il reste à faire
Le noyau (séquence, alignement, GenBank/Swissprot, enzymes de restriction) est solide et testé. Quinze parseurs de bases externes restent à porter — KEGG et ses 1672 lignes en tête, à décomposer en services distincts plutôt qu’en portage monolithique. Et comme toujours dans ce genre de session marathon : rien n’est committé tant que je n’ai pas tout revalidé à froid.
Ce qui me marque, en écrivant ce billet : presque tous les vrais bugs trouvés pendant ces trois jours n’avaient rien à voir avec la modernisation elle-même. Ils dormaient dans le code depuis des années — une variable mal nommée, un @todo jamais vérifié, une table de correspondance incomplète — et c’est le simple fait de tout revalider en profondeur, pas seulement de faire passer composer install, qui les a fait remonter.









