Le problème devient visible quand un projet Symfony reflète l’histoire de l’équipe plutôt que le métier. Les mêmes règles traversent contrôleurs, entités, listeners et commandes ; une correction urgente de facturation oblige à relire le catalogue, les contrats et les notifications. Les délais s’allongent, les régressions reviennent et chaque équipe attend une personne qui connaît encore les couplages cachés.
Le vrai enjeu est inverse au réflexe d’extraction : il faut d’abord obtenir une frontière de responsabilité dans le code, puis choisir le conditionnement le moins coûteux qui la protège. Un namespace interne peut suffire. Un package Composer devient utile quand le cycle de livraison ou la réutilisation est réel. Un bundle Symfony se justifie lorsque le code doit s’installer et se configurer comme une extension autonome.
Contre-intuitivement, le monolithe le mieux modularisé contient parfois zéro bundle métier. Les recommandations officielles de Symfony réservent les bundles aux fonctionnalités réutilisables de façon autonome et proposent d’organiser la logique applicative avec les namespaces. Multiplier les manifestes et extensions sans autonomie ne fait que déplacer le couplage.
Pour une application métier Symfony sur mesure, la décision se prend donc sur les changements, les propriétaires, les données et le run. Le coût caché se trouve dans les releases coordonnées, les compatibilités à maintenir et le temps de diagnostic entre dépôts. L’évaluation fournit des seuils locaux et une trajectoire réversible pour éviter le dossier fourre-tout comme la constellation de packages.
Partir du coût de changement observé
La modularisation répond à un problème mesurable : changements qui touchent trop de zones, cycles de dépendances, tests qui nécessitent tout le noyau ou équipes qui se bloquent. « Le projet est gros » n’est pas un diagnostic. Un code de 200 000 lignes peut évoluer correctement si ses responsabilités restent explicites ; un code bien plus petit peut être figé par trois services centraux.
L’équipe échantillonne des évolutions récentes et trace les fichiers, données, validations et déploiements concernés. Si huit demandes de facturation sur dix modifient aussi le catalogue sans raison métier, la couture est probablement mauvaise. Si les changements traversent plusieurs domaines parce que le processus le demande réellement, séparer davantage ne supprimera pas la coordination.
Signal faible. Un dossier Shared, Common ou Core qui reçoit chaque nouvelle abstraction indique souvent une dépendance inversée : les modules concrets dépendent d’un centre sans propriétaire. Avant de l’extraire, il faut nommer la capacité fournie et le consommateur qui en a besoin.
Distinguer module, package et bundle
Le module interne organise une application
Un module interne est une convention d’architecture dans le même dépôt et le même déploiement. Il regroupe modèle, cas d’usage, ports et adaptateurs autour d’une capacité. Il peut conserver l’autowiring Symfony, partager le pipeline et évoluer dans la même pull request que ses consommateurs. Sa légèreté en fait le choix par défaut.
Le package Composer apporte une unité de distribution
Un package possède son autoload, ses dépendances déclarées, ses tests et une version. Il convient lorsqu’au moins deux applications consomment la même capacité stable ou lorsqu’une équipe doit la livrer selon un cycle distinct. Cette indépendance ajoute des releases, des compatibilités et un diagnostic inter-dépôts qu’il faut financer.
Le bundle ajoute une intégration Symfony installable
Un bundle configure des services, expose éventuellement recettes, commandes, migrations ou extensions. Il sert un package conçu pour être branché dans plusieurs applications Symfony. La documentation des bonnes pratiques Symfony déconseille d’en créer pour seulement organiser la logique interne d’une application.
Dessiner les frontières avec le métier
Une frontière robuste entoure des invariants et un vocabulaire. Facturation décide quand une facture devient exigible ; catalogue décrit ce qui peut être vendu ; identité détermine qui agit. Si deux modules doivent valider ensemble la même règle à chaque opération, ils ne sont peut-être pas réellement séparés ou il manque un orchestrateur de processus.
Les scénarios de changement révèlent mieux la frontière qu’un diagramme de classes. L’équipe pose des questions : peut-on modifier le calcul de taxe sans changer les commandes ? Peut-on livrer une nouvelle règle d’éligibilité sans redéployer l’identité ? Les réponses distinguent autonomie souhaitée et autonomie réellement nécessaire.
Le langage doit rester local. Un Client dans le CRM peut être un Compte payeur dans la facturation et un Destinataire dans la livraison. Imposer une entité commune évite une duplication apparente, mais crée un objet dont chaque champ appartient à un propriétaire différent.
Orienter les dépendances sans cycles
La règle la plus utile est visible : un module peut dépendre d’un contrat public, jamais des classes internes d’un voisin. Les namespaces Internal ou Infrastructure ne sont pas importés depuis l’extérieur. Une interface n’inverse pourtant pas magiquement le couplage ; elle doit appartenir au consommateur et exprimer son besoin.
Les cycles sont traités comme un défaut de responsabilité. Si Commande appelle Facturation qui rappelle Commande, un processus applicatif peut posséder la séquence, ou un événement peut transmettre un fait déjà établi. Publier des événements pour contourner chaque import circulaire déplace seulement le cycle vers le temps.
Seuil local à qualifier. Une équipe peut bloquer en CI toute nouvelle dépendance interdite et tolérer temporairement une liste documentée de cycles historiques. Le nombre acceptable n’est pas universel ; la règle importante est que la liste diminue et que chaque exception possède un propriétaire et une date de réexamen.
Choisir les contrats entre modules
Un appel synchrone convient quand le consommateur a besoin du verdict pour poursuivre. Il passe par un service public ou un port étroit, avec des objets d’entrée et de sortie stables. Exposer un repository voisin donne accès à des possibilités que le contrat n’assume pas et permet de reconstruire une règle depuis l’extérieur.
Un événement convient pour annoncer un fait durable : contrat signé, facture émise, compte suspendu. Il n’est pas une commande déguisée. Le producteur ne dépend pas du succès immédiat des consommateurs, et le run prévoit retard, répétition et évolution de schéma. Le nom au passé aide, mais la sémantique et l’identifiant d’événement sont décisifs.
Pour une lecture transverse, une projection dédiée évite les jointures sauvages entre modèles. Elle accepte une fraîcheur explicite et appartient à son usage. Cette duplication contrôlée coûte moins cher qu’une base commune que personne n’ose faire évoluer.
Éviter la copropriété des données
Chaque donnée décisionnelle possède un propriétaire. D’autres modules gardent un identifiant et, si nécessaire, une copie qualifiée par sa fraîcheur. Deux modules qui écrivent la même colonne n’ont pas une intégration : ils ont une responsabilité partagée et un incident futur difficile à attribuer.
Dans un monolithe, les tables peuvent rester dans la même base sans perdre cette propriété. Des schémas séparés peuvent renforcer la discipline, mais ils ne remplacent pas les droits et les règles de code. L’accès SQL direct à la table d’un voisin est interdit ou recensé avant toute extraction.
Les transactions traversant plusieurs modules sont examinées au cas par cas. Une transaction locale reste possible tant que le déploiement est commun. Si l’autonomie future exige une saga, l’équipe ne l’introduit pas par anticipation sans besoin ; elle rend d’abord les étapes et compensations explicites.
Extraire un package au bon moment
Trois preuves rendent l’extraction crédible : plusieurs consommateurs réels, une API plus stable que son implémentation et un propriétaire capable de publier. Copier un dossier dans un dépôt privé sans ces preuves ajoute un aller-retour de version à chaque correction coordonnée.
Le package déclare uniquement les dépendances nécessaires et suit le schéma officiel de Composer. Les dépendances de développement ne fuient pas vers le consommateur. Les tests de compatibilité couvrent les versions PHP et Symfony réellement supportées ; une matrice ambitieuse sans environnement utilisateur ne vaut rien.
Le premier pilote peut utiliser un path repository ou un subtree dans le monorepo afin de valider l’API sans multiplier immédiatement les dépôts. Le passage à une release indépendante intervient lorsque deux consommateurs doivent réellement avancer à des rythmes différents.
Réserver le bundle au code installable
Un bundle pertinent encapsule l’intégration Symfony d’une capacité : extension de conteneur, configuration validée, tags, commandes et points d’extension. Son installation doit fonctionner dans une application minimale sans connaître l’organisation interne du projet source. Sinon, il s’agit probablement d’un module déguisé.
La configuration publique reste courte et orientée capacité. Exposer chaque nom de service ou chaque classe interne transforme le bundle en assemblage distant. Les valeurs par défaut sont sûres, les erreurs de configuration apparaissent au démarrage et la désinstallation ne laisse pas de routes ou de services fantômes.
Cas de refus. Un domaine Commande utilisé dans une seule application, déployé avec elle et modifié avec ses écrans ne gagne rien à devenir un bundle. Le namespace, les tests d’architecture et un service public protègent déjà sa frontière avec beaucoup moins de cérémonial.
Faire respecter l’architecture en CI
Une règle qui vit seulement dans un document disparaît sous la pression. Un analyseur de dépendances vérifie les namespaces autorisés, les couches et les imports interdits. Composer valide les contraintes des packages. Les tests d’intégration vérifient que chaque module se construit avec ses adaptateurs attendus.
La CI ne doit pas imposer une théorie impossible à diagnostiquer. Un message précise la dépendance interdite, la règle et les options : passer par le contrat public, déplacer le cas d’usage ou documenter temporairement l’exception. La liste d’exceptions reste versionnée et revue.
Les tests unitaires n’exigent pas systématiquement le kernel. Si un cas d’usage interne ne peut être instancié sans vingt services Symfony, la frontière d’infrastructure est probablement trop poreuse. À l’inverse, les tests d’intégration doivent démarrer le vrai conteneur pour révéler tags et décoration.
Versionner sans bloquer le monolithe
Dans un même dépôt et une même release, les contrats internes peuvent évoluer atomiquement avec leurs consommateurs. Simuler du versionnement sémantique entre dossiers ralentit sans ajouter d’autonomie. Une dépréciation courte et un test de dépendance suffisent souvent.
Un package consommé par plusieurs applications suit une politique explicite. Une rupture majeure ne signifie pas forcément un grand numéro annuel : elle exige une période de coexistence, une procédure de migration et la capacité à corriger la version précédente pendant la transition. Les consommateurs choisissent leur fenêtre, le producteur publie les preuves de compatibilité.
Les événements persistés demandent une attention supérieure aux appels PHP. Leur schéma survit aux déploiements et aux retries. Le consommateur doit lire les versions encore présentes dans la file ; le producteur n’efface pas un champ tant que cette durée n’est pas dépassée et observée.
Cas concret : facturation multi-produits
Exemple concret. Une plateforme vend trois offres. La facturation, initialement dispersée, dépend directement des entités de commande et de catalogue. Chaque ajout de produit modifie neuf listeners ; les tests démarrent toute l’application ; une correction de taxe exige un déploiement coordonné avec le portail.
Le pilote crée un module Facturation interne. Il possède échéanciers, règles de taxe et émission, reçoit des références de contrat et publie FactureÉmise. Le catalogue fournit une description tarifaire via un port demandé par Facturation. Aucun package n’est créé : une seule application et une seule équipe livrent encore l’ensemble.
Sur six semaines, deux offres et 5 000 factures historiques sont rejouées en mode comparaison. Le go local exige l’absence d’écart financier inexpliqué, une durée compatible avec la clôture et zéro accès direct du nouveau module aux tables de commande. Ces seuils viennent du risque comptable du cas, pas d’une règle générale de modularisation.
Un seuil de passage lié au coût de changement
Le seuil de passage est réexaminé après chaque offre : si le délai de correction ou le nombre d’exceptions de dépendance augmente, l’équipe conserve le périmètre actuel au lieu d’extraire davantage. Cette règle relie l’architecture au coût de changement observé.
Un an plus tard, une seconde application doit émettre avec les mêmes règles. L’équipe extrait le noyau de calcul dans un package, mais garde l’orchestration et la persistance dans chaque application. Un bundle n’apparaît que si l’intégration Symfony répétée devient elle-même un produit stable.
Modulariser progressivement un existant
La migration commence par cartographier les dépendances et sélectionner une couture où le coût est visible. L’équipe écrit des tests de caractérisation autour des résultats actuels, nomme un contrat public et fait passer un premier appel par ce contrat sans déplacer toutes les classes.
Ensuite, elle déplace les décisions une par une et bloque les nouveaux imports vers l’ancien emplacement. Les lectures transverses sont remplacées par des requêtes dédiées ou des projections. Les tables peuvent rester en place pendant cette phase ; mélanger réorganisation de code et migration physique des données rend le rollback inutilement dangereux.
Le retour arrière conserve l’ancien chemin derrière une bascule courte tant que les résultats sont comparables. Une fois le nouveau propriétaire confirmé, l’écriture historique est supprimée avant les lectures. Une double écriture durable créerait précisément la copropriété que la modularisation cherche à enlever.
Exploiter les modules sans perdre la vue globale
Un monolithe modulaire reste un système unique à déployer, mais ses signaux portent module, cas d’usage et corrélation. L’équipe peut attribuer une alerte de facturation sans créer un service réseau. Les tableaux de bord montrent la chaîne métier et permettent de traverser plusieurs modules lors d’un incident.
Chaque module déclare ses migrations, commandes planifiées et dépendances critiques. L’ordre de déploiement reste commun ; les propriétaires préparent les compatibilités de schéma. Une frontière de code qui rend une bascule de base impossible n’est pas encore une frontière d’exploitation.
Le runbook évite le diagnostic par dépôt. Il part du symptôme utilisateur, puis localise la responsabilité. Extraire un package ou un service uniquement pour obtenir une équipe d’astreinte séparée est un signal organisationnel à discuter avant de le graver dans le réseau.
Pour qui une séparation devient utile
Les projets concernés ont plusieurs capacités métier, un historique de changements transverses ou plusieurs équipes qui se bloquent. Ils bénéficient d’abord d’un module interne. Les packages servent les organisations qui possèdent plusieurs applications et un besoin réellement commun ; les bundles servent les intégrations Symfony distribuées.
Une petite application stable, une équipe unique et peu de règles ne gagne pas à multiplier les couches. Un dossier par capacité et des tests ciblés suffisent. Le coût de mise à jour, de publication et de documentation d’un package dépasserait la friction qu’il prétend enlever.
Le métier possède les invariants, l’équipe produit possède le module, la plateforme aide à l’outillage et un mainteneur possède chaque package. « Tout le monde » n’est pas un propriétaire. L’extraction est refusée tant qu’une personne ne peut pas arbitrer API, compatibilité et incidents.
Éviter les abstractions qui coûtent plus cher
- Créer un bundle par domaine. Le conteneur compile davantage de configuration, mais les entités et transactions restent croisées. Le conditionnement ne corrige pas la responsabilité.
- Extraire trop tôt dans plusieurs dépôts. Chaque évolution coordonnée nécessite versions, publications et mises à jour. Il vaut mieux stabiliser l’API dans le monorepo avant de payer cette distance.
- Partager les entités Doctrine. Le package prétend être autonome mais impose le schéma du producteur. Les contrats publics utilisent des objets propres et la persistance reste un adaptateur.
- Utiliser les événements pour tout. Une validation qui exige une réponse immédiate devient une chorégraphie opaque. L’appel synchrone est parfois la frontière la plus honnête.
- Mesurer le nombre de modules. La bonne mesure est le coût de changement, les cycles supprimés, le temps de test et la clarté du propriétaire. Plus de boîtes n’est pas plus d’architecture.
Plan d’action : conduire un pilote en huit semaines
Semaines 1 et 2 : prouver la douleur
L’équipe analyse dix évolutions et cinq incidents, produit le graphe de dépendances et identifie les données écrites par plusieurs zones. Elle choisit une capacité dont les règles sont compréhensibles, le propriétaire disponible et le risque de migration borné.
Elle définit trois résultats locaux : réduire les fichiers touchés pour une évolution type, supprimer un cycle précis et exécuter le cœur des tests sans kernel. Ces objectifs ne deviennent pas des normes de taille pour les autres modules.
Semaines 3 à 5 : créer la frontière interne
L’équipe introduit le namespace, le service public et les objets de contrat. Elle déplace une décision à la fois, ajoute le contrôle de dépendances et remplace les accès aux tables voisines. Les migrations de données restent hors du premier passage sauf nécessité démontrée.
Deux scénarios réels sont livrés par le nouveau chemin. L’ancien reste disponible pour comparaison, puis sa nouvelle utilisation est interdite. Les journaux portent la capacité afin que support et exploitation évaluent la lisibilité du résultat.
Semaines 6 à 8 : décider du conditionnement
La revue compare coût de changement, durée des tests, incidents et dépendances. Si le module n’a qu’un consommateur et un cycle commun, il reste interne. Si plusieurs applications le demandent avec une API stable, un package pilote est publié. Le bundle n’est ajouté que pour une intégration Symfony répétée.
La sortie comprend entrées, sorties, responsabilités, dépendances, journalisation, compatibilité, procédure de release et retour arrière. L’équipe étend à une autre capacité seulement si le premier module réduit effectivement les coordinations et si les exceptions de dépendances reculent.
- À faire d’abord : nommer la capacité, ses invariants et son propriétaire.
- À corriger ensuite : bloquer les imports internes et supprimer un cycle réel.
- À valider avant extraction : prouver plusieurs consommateurs et une API stable.
- À différer : le dépôt séparé tant que les changements restent coordonnés.
- À refuser : un bundle qui ne peut pas être installé dans une application minimale.
Approfondir domaine et monolithe modulaire
Pour relier les frontières au langage métier, l’usage proportionné du DDD aide à éviter les agrégats et couches décoratives.
Quand le sujet est surtout la croissance du code, l’organisation d’un projet Symfony prolonge la méthode. Le choix entre monolithe modulaire et microservices aide ensuite à qualifier une autonomie de déploiement réelle.
Conclusion : extraire seulement une frontière prouvée
La modularisation réussie rend les responsabilités et les changements plus locaux. Elle commence par les invariants, les données et les contrats, bien avant le choix d’un dépôt ou d’un bundle.
Le module interne est le niveau par défaut : rapide à faire évoluer, testable et compatible avec une release atomique. Le package finance une distribution indépendante. Le bundle finance en plus une intégration Symfony installable. Chaque marche exige une preuve supplémentaire.
Une bonne frontière réduit les cycles, clarifie le propriétaire et améliore le diagnostic. Si elle ajoute surtout des versions, des façades et des événements sans enlever de coordination, il faut revenir au problème initial plutôt que défendre la forme choisie.
Si votre projet Symfony se fragmente ou reste bloqué par des dépendances transverses, Dawap peut vous accompagner pour cartographier les coutures, éprouver un pilote et construire une architecture web métier évolutive.