Cinq applications Symfony pour le même grand compte, et les mêmes besoins qui reviennent dans chacune : déposer des fichiers sur un stockage objet, envoyer des SMS, négocier des tokens OAuth, sauvegarder une base, notifier une équipe. La première fois, on copie la classe d'un projet à l'autre. La deuxième fois aussi. La troisième fois, le copier-coller a déjà divergé : trois versions du même code, trois comportements subtilement différents, trois endroits où corriger le prochain bug.
La réponse évidente — « on n'a qu'à faire une librairie commune » — est aussi l'un des pièges les plus documentés du génie logiciel : la librairie interne qui couple tout ce qu'elle touche, impose ses dépendances à tout le monde et casse quelque chose à chaque montée de version. Ce retour d'expérience raconte l'inverse : une librairie PHP interne partagée entre cinq applications d'un grand opérateur télécom — celles de la mission au long cours déjà évoquée dans cette série —, maintenue depuis trois ans, vingt-sept versions publiées, et un seul changement cassant dans l'API du quotidien. Voici les quatre décisions qui rendent la mutualisation rentable au lieu de toxique.
Les deux façons de rater une librairie partagée
Avant le comment, le pourquoi. Une librairie interne échoue de deux façons opposées, et il faut les regarder en face toutes les deux.
Par divergence, d'abord : c'est l'échec du copier-coller. Chaque projet héberge sa copie des mêmes utilitaires, les corrections ne se propagent pas, et au bout de deux ans personne ne sait plus quelle version fait foi. Le coût est invisible au début — coller un fichier prend dix secondes — et s'accumule en silence, bug par bug.
Par couplage, ensuite : c'est l'échec de la librairie fourre-tout. Tout le monde dépend d'elle, elle dépend de tout, et chaque montée de version devient un projet de migration synchronisé entre toutes les équipes. Au premier breaking change mal géré, les consommateurs épinglent une vieille version « en attendant » — et la librairie commune devient un copier-coller qui ne dit pas son nom, avec la complexité d'un dépôt partagé en prime.
Une mutualisation réussie n'est donc pas une question d'outillage : c'est une question de contrat entre la librairie et ses consommateurs. Tout ce qui suit découle de cette idée.
Décision 1 — Les soft dependencies : ne payer que ce qu'on consomme
Premier réflexe en ouvrant le composer.json d'une librairie interne :
compter les dépendances. Celle-ci en exige quatre, et aucune n'engage à rien :
{
"require": {
"php": ">=8.4",
"ext-iconv": "*",
"ext-mbstring": "*",
"psr/log": "^3.0"
}
}
Pourtant la librairie contient une dizaine de composants, dont certains
s'appuient sur le client HTTP Symfony, le rate limiter, le SDK AWS ou Doctrine.
L'astuce s'appelle soft dependencies : ces paquets sont en
require-dev de la librairie — une vingtaine, pour exécuter sa propre
suite de tests — mais ne sont pas imposés aux consommateurs. Chaque
composant documente ce qu'il requiert, et c'est le projet consommateur qui fait
le composer require correspondant, uniquement s'il active le
composant.
Même logique pour la configuration : un fichier YAML par composant, livré avec la librairie, que le consommateur importe sélectivement :
# chaque application n'importe que les composants qu'elle utilise
imports:
- ../vendor/acme/php-tools/config/token.yaml
- ../vendor/acme/php-tools/config/sms.yaml
# pas de s3.yaml : cette application n'en a pas besoin
Le résultat se mesure dans les cinq applications : aucune n'embarque le SDK AWS si elle ne touche pas au stockage objet, aucune ne configure de rate limiter si elle n'envoie pas de SMS. La librairie grossit sans alourdir personne — c'est la condition pour pouvoir y ajouter des composants sans culpabiliser. Le prix à payer est une discipline de documentation : chaque composant a sa page, qui liste ses soft dependencies, ses variables d'environnement et son import YAML. Une heure d'écriture par composant, qui remplace des heures de débogage chez chaque consommateur.
Décision 2 — Le pattern bridge : une interface, une implémentation, un fake
Le cœur de la librairie, ce sont ses bridges : des ponts vers les services externes du groupe — API SMS, stockage S3, webhooks de messagerie d'équipe, fournisseur de tokens OAuth, référentiels internes. Chaque bridge suit exactement la même structure :
src/Bridge/Sms/
├── SmsInterface.php # le contrat consommateur : send(…)
├── Sms.php # l'implémentation réelle (OAuth, rate limit, API)
└── FakeSms.php # l'implémentation hors-ligne et déterministe
L'interface n'expose que ce dont les consommateurs ont besoin — pas les détails du transport. La configuration livrée avec le composant câble l'alias par défaut vers l'implémentation réelle, et chaque projet le renverse dans son environnement de test :
imports:
# câblage par défaut fourni par la librairie : SmsInterface → Sms
- ../vendor/acme/php-tools/config/sms.yaml
when@test:
services:
# en test : le fake, déterministe et hors-ligne
Acme\Bridge\Sms\SmsInterface: '@Acme\Bridge\Sms\FakeSms'
Les lecteurs de
l'article sur les tests e2e de l'image de production
reconnaîtront ce montage : c'est précisément ce qui permet de démarrer l'image
Docker de production en CI, sans réseau vers les API internes, et d'y dérouler
une vraie suite Playwright. Le point important est que le fake fait
partie de la librairie, pas des projets. Il est écrit une fois, testé
avec l'implémentation réelle, et reproduit le contrat de validation : le
FakeSms rejette un numéro invalide ou un message trop long
exactement comme le vrai bridge — il s'arrête juste avant l'appel réseau. Un
fake naïf qui dit oui à tout laisse passer en test ce que la production
refusera ; un fake qui partage les validations du réel attrape ces bugs là où
ils coûtent le moins cher.
Décision 3 — La rétrocompatibilité comme discipline par défaut
Trois ans de vie, vingt-sept versions taguées, cinq consommateurs en production. Le bilan des changements cassants tient en trois lignes : une version majeure pour le passage à PHP 8.4 — un prérequis de plateforme, pas un changement d'API —, une pour la réorganisation d'un composant, et une seule rupture dans l'API du quotidien : un renommage de classe utilitaire et un déplacement de méthode, accompagnés dans le changelog d'un guide de migration qui tient en deux remplacements.
# [4.0.0]
## Breaking changes
- `Acme\Utils\StrUtil` renamed to `Acme\Utils\StrUtils`.
Migration : remplacer l'import et le préfixe aux points d'appel.
# [4.3.1]
## Added
- `SmsInterface::send()` — nouveau paramètre optionnel `?string $notifyUrl`
(défaut `null` ; les appels existants ne changent pas).
L'extrait de la 4.3.1 montre la règle en action : additif d'abord. Un besoin nouveau — recevoir l'accusé de réception d'un SMS — s'ajoute comme paramètre optionnel avec valeur par défaut, pas comme une nouvelle signature obligatoire. Les cinq consommateurs continuent de fonctionner sans toucher une ligne ; celui qui a besoin de la fonctionnalité l'adopte quand il veut. Et lorsqu'un changement touche malgré tout un point d'extension — les projets qui fournissent leur propre implémentation de l'interface —, le changelog le dit explicitement, au lieu de laisser la CI du consommateur le découvrir.
Cette discipline a une conséquence visible dans les composer.json :
aujourd'hui, deux applications consomment la version 4 et trois vivent encore
très bien en version 3. Personne n'a été forcé de migrer. Chaque
équipe monte de version quand son calendrier le permet, parce que monter de
version est un non-événement — et c'est exactement pour ça qu'elles finissent
par le faire. La confiance dans la librairie se construit release après release,
et se détruirait en une seule.
Décision 4 — Un changelog qui remplace des réunions
Le dernier pilier n'est pas technique : c'est le changelog, tenu au format Keep a Changelog, où chaque version documente ce qui est ajouté, modifié ou cassé — du point de vue du consommateur, pas du mainteneur. « Nouveau paramètre optionnel, les appels existants ne changent pas » est une information actionnable ; « refacto du bridge SMS » n'en est pas une.
Dans un contexte multi-équipes, ce document fait office de canal de coordination : pas de réunion de synchronisation, pas d'annonce à faire passer, pas de tableau des versions à maintenir ailleurs. Une équipe qui envisage un bump lit le changelog entre sa version et la cible, et sait en cinq minutes ce qui l'attend — y compris, pour l'unique break d'API, le temps exact de migration. C'est la version écrite et asynchrone de la confiance : le mainteneur s'engage sur ce qu'il publie, les consommateurs vérifient au lieu de deviner.
Le coût asymétrique de la rétrocompatibilité
Tout cela a un coût, et il faut le nommer pour comprendre pourquoi il est rentable. Maintenir la compatibilité coûte au mainteneur : des paramètres optionnels au lieu de signatures propres, du code de transition, des dépréciations à gérer, un changelog à écrire. Casser coûte aux consommateurs : chaque rupture se paie autant de fois qu'il y a de projets, plus la coordination, plus le risque que l'un d'eux reporte la migration et s'enferme sur une version figée.
L'asymétrie saute aux yeux dès qu'on pose les ordres de grandeur : un effort localisé chez une personne qui connaît intimement le code, contre un effort multiplié par cinq chez des équipes qui ont d'autres priorités. Avec cinq consommateurs, une heure de précaution côté librairie économise des demi-journées cumulées côté projets — et le calcul penche un peu plus à chaque nouveau consommateur. C'est le même raisonnement qui fait préférer les migrations additives dans un rolling update : quand deux versions doivent cohabiter, la charge de la compatibilité revient à celui qui publie, pas à ceux qui consomment.
L'inverse mérite d'être dit aussi : une librairie interne qui casse souvent est pire que du copier-coller. Elle cumule les inconvénients — le couplage du code partagé, la divergence des versions épinglées — sans le seul avantage du copier-coller, qui est de ne rien promettre à personne.
Quand ne pas mutualiser
Un REX honnête doit aussi tracer la frontière. Tout ne va pas dans la librairie, et les critères sont assez nets après trois ans de pratique.
Le code métier n'y entre jamais. Les règles de gestion d'une
application appartiennent à cette application ; les mutualiser créerait du
couplage fonctionnel entre des produits qui évoluent à des rythmes différents.
La librairie n'accueille que du transverse : des ponts vers des services
partagés, des utilitaires sans état, de l'outillage d'exploitation — le genre
de code dont la sauvegarde de base de données est l'exemple type, améliorée une
fois (mysqldump --single-transaction --quick, pour ne plus
verrouiller les écritures pendant le backup) et déployée partout par un simple
bump de patch.
L'extraction attend la répétition. La règle de trois s'applique : un besoin rencontré une fois reste dans son projet ; deux fois, on note la duplication ; trois fois, on extrait. Extraire trop tôt, c'est généraliser à partir d'un seul cas — et se tromper sur l'API. Chaque composant de la librairie est né d'un besoin réel dans au moins deux applications.
Et chaque composant paie un ticket d'entrée : une interface, un fake déterministe, une page de documentation, un fichier de configuration, la liste de ses soft dependencies. Si un bout de code ne mérite pas cet investissement, c'est qu'il n'est pas mûr pour être partagé — et c'est une information utile, pas un échec.
Bonus : publier et consommer le paquet via GitLab
Reste la question logistique, qui revient à chaque fois qu'on parle de librairie interne : où héberger le paquet ? Inutile de payer un Packagist privé ou de maintenir un Satis — l'instance GitLab qui héberge déjà le code fait registry Composer. Côté librairie, la publication tient dans un job de CI déclenché à chaque tag :
publish:
stage: publish
only: [ tags ]
script:
# enregistre la version taguée sur le registry Composer du projet
- >
curl --fail-with-body --request POST
--header "JOB-TOKEN: ${CI_JOB_TOKEN}"
--data "tag=${CI_COMMIT_TAG}"
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/composer"
Un détail conceptuel qui surprend : ce curl ne téléverse
rien. Le registry Composer de GitLab ne stocke pas d'artefact —
« publier » consiste à enregistrer la référence du tag, et le paquet est
le dépôt Git à ce tag, décrit par son composer.json. Pas de build,
pas de zip, pas de stockage à gérer : taguer, c'est livrer. Côté consommateur,
on déclare la source puis on requiert le paquet comme n'importe quel autre :
# déclarer le registry Composer du groupe GitLab comme source de paquets
composer config repositories.internal composer \
"https://gitlab.example.com/api/v4/group/<group_id>/-/packages/composer/packages.json"
composer require 'acme/php-tools:^4.0'
Le seul point de friction réel est l'authentification, et elle se règle
différemment selon le contexte. En local, un personal access token avec le
scope read_api suffit. En CI, surtout pas de token personnel à faire
circuler dans des variables : chaque job dispose déjà d'un
CI_JOB_TOKEN éphémère, qui meurt avec lui :
# en local : personal access token (scope read_api)
composer config --global http-basic.gitlab.example.com __token__ "${TOKEN}"
# en CI : le job token éphémère fourni par GitLab
composer config http-basic.gitlab.example.com gitlab-ci-token "${CI_JOB_TOKEN}"
Et le piège qui coûte une demi-journée à qui ne le connaît pas : depuis
GitLab 16, le job token est soumis à une allowlist. Le projet de
la librairie doit autoriser explicitement chaque projet consommateur
(Settings → CI/CD → Job token permissions), sinon le
composer install du consommateur échoue — avec un 404,
pas un 403, comme si le paquet n'existait pas. On cherche d'abord une faute de
frappe dans l'URL, puis un problème de version… alors que c'est une case à
cocher dans le projet d'en face.
Conclusion
Mutualiser sans coupler tient en quatre décisions : des soft dependencies pour que chacun ne paie que ce qu'il consomme, des bridges interface + fake pour que le code partagé reste testable partout, une rétrocompatibilité par défaut pour que monter de version soit un non-événement, et un changelog tenu sérieusement pour que la coordination soit écrite plutôt que réunionnée. Aucune de ces décisions n'est techniquement difficile ; toutes sont des engagements de discipline dans la durée.
Le bénéfice, lui, se lit en filigrane de cette série d'articles : la même librairie fournit les fakes qui rendent l'image de production testable en CI, l'outillage de sauvegarde de toutes les applications, et les ponts vers les services du groupe — écrits une fois, corrigés une fois, déployés cinq fois. Trois ans, vingt-sept versions, un seul break d'API. La mutualisation n'est pas un sujet d'outillage : c'est un contrat qu'on honore, version après version.