Skip to content

Bundles — anatomy, AbstractBundle, when (not) to write one

TL;DR — Depuis Symfony 4 + Flex, ton app n'est plus un bundle. Tu écris du code applicatif dans src/, et tu ne crées un bundle que pour partager du code entre projets. AbstractBundle (5.4+) simplifie drastiquement les bundles : config typée, extension auto, plus de DependencyInjection\Configuration.php séparé.

🧠 Mental model — ASCII diagram + analogie

   ┌─────────────────────────────────────────────────┐
   │                   YOUR APP                       │
   │   src/  config/  templates/  public/             │
   │   (no bundle, just PSR-4 + Flex)                 │
   │                                                  │
   │   uses ───────────────► uses ─────────►          │
   └────────┬────────────────────────┬────────────────┘
            │                        │
            ▼                        ▼
   ┌────────────────────┐   ┌────────────────────┐
   │ FrameworkBundle    │   │ AcmeBlogBundle     │
   │ (built-in)         │   │ (your shared lib)  │
   │ ─ Bundle class     │   │ ─ AbstractBundle   │
   │ ─ Extension        │   │ ─ Configuration    │
   │ ─ Resources/config │   │ ─ Resources/config │
   │ ─ DI services      │   │ ─ DI services      │
   └────────────────────┘   └────────────────────┘

Analogie : Bundle = plugin WordPress (mais propre). Ton app est l'instance WP, les bundles sont les plugins partageables. Un plugin pour un seul site n'a aucun sens → tu mets le code dans le thème. De même : code spécifique à ton app = src/, code réutilisable = bundle séparé (souvent dans son propre repo Composer).

🛠️ Code minimal — AbstractBundle moderne (5.4+)

php
// vendor/acme/blog-bundle/src/AcmeBlogBundle.php
<?php
namespace Acme\BlogBundle;

use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;

final class AcmeBlogBundle extends AbstractBundle
{
    protected string $extensionAlias = 'acme_blog';

    public function configure(DefinitionConfigurator $definition): void
    {
        $definition->rootNode()
            ->children()
                ->booleanNode('enabled')->defaultTrue()->end()
                ->integerNode('items_per_page')->defaultValue(20)->min(1)->max(100)->end()
                ->scalarNode('cache_pool')->defaultValue('cache.app')->end()
                ->arrayNode('feeds')
                    ->arrayPrototype()
                        ->children()
                            ->scalarNode('url')->isRequired()->end()
                            ->scalarNode('format')->defaultValue('atom')->end()
                        ->end()
                    ->end()
                ->end()
            ->end();
    }

    public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
    {
        if (!$config['enabled']) {
            return;
        }

        $container->import('../config/services.yaml');

        $builder->setParameter('acme_blog.items_per_page', $config['items_per_page']);
        $builder->setParameter('acme_blog.feeds', $config['feeds']);

        $builder->getDefinition('acme_blog.cache')
            ->replaceArgument(0, new \Symfony\Component\DependencyInjection\Reference($config['cache_pool']));
    }
}
yaml
# vendor/acme/blog-bundle/config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    Acme\BlogBundle\:
        resource: '../src/'
        exclude:
            - '../src/{Entity,Tests,AcmeBlogBundle.php}'

    acme_blog.cache:
        alias: cache.app
        public: false
php
// config/bundles.php — dans l'app utilisatrice
return [
    // ...
    Acme\BlogBundle\AcmeBlogBundle::class => ['all' => true],
];
yaml
# config/packages/acme_blog.yaml
acme_blog:
    items_per_page: 25
    cache_pool: cache.redis
    feeds:
        - { url: 'https://example.com/rss', format: 'rss' }

🎯 Patterns courants

  1. AbstractBundle (5.4+) : single file. configure() + loadExtension() + $extensionAlias. Plus besoin de séparer Extension/Configuration classes.
  2. Semantic config — exposer des options métier (items_per_page) plutôt que des arguments DI bruts. L'utilisateur du bundle ne devrait jamais avoir à connaître les noms internes des services.
  3. Compiler pass interne : build(ContainerBuilder $container) dans le Bundle pour ajouter une pass spécifique au bundle (tag collection, etc.).
  4. Bundle + Flex recipe : recipe/manifest.json qui auto-configure le bundle à l'install (composer require acme/blog-bundle). Crée les fichiers config par défaut, ajoute env vars, etc.
  5. Bundle prependable — implémente PrependExtensionInterface::prepend() pour injecter de la config dans d'autres bundles (ex: ajouter une route Doctrine sans demander à l'utilisateur de configurer Doctrine).
  6. Resources path standardResources/config, Resources/translations, Resources/views/, Resources/public/. Symfony auto-loadera tout sans config si tu respectes la convention.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

  • 5.4 : introduction de AbstractBundle. L'ancien pattern Bundle + Extension + Configuration reste 100% supporté. BundleInterface toujours stable.
  • 6.0 : Flex devient le standard de facto. La doc officielle ne montre plus de bundle wrapping pour ton app. Resources/ n'est plus créé par les makers pour l'app.
  • 6.1 : DefinitionConfigurator reçoit import(), possibilité de split la config en plusieurs fichiers.
  • 6.4 LTS : AbstractBundle::loadExtension() reçoit array $config complet (avec defaults appliqués). Config typée via PHPStan/Psalm si tu déclares @phpstan-type.
  • 7.0 : suppression de plusieurs méthodes deprecated dans BundleInterface (ex: l'ancien setContainer() hérité de ContainerAwareInterface). En legacy custom, getContainerExtension() doit toujours retourner une instance d'ExtensionInterface (ou null) — avec AbstractBundle, c'est géré automatiquement et tu n'as rien à écrire.
  • 7.1+ : MicroKernelTrait et l'autoconfiguration continuent de réduire le boilerplate ; AbstractBundle::loadExtension() reste le point d'entrée canonique pour brancher services et paramètres.

⚠️ Pitfalls

  1. Wrapper ton app dans un bundle — anti-pattern depuis 4.0. Tu te tires une balle dans le pied : config dupliquée, namespace verbeux, conflict avec Flex. Reste en App\ PSR-4.
  2. Bundle qui dépend d'une autre dépendance non-déclarée — toujours composer require dans le composer.json du bundle, jamais "tu auras qu'à l'installer".
  3. Hardcoder le nom du service externe — utiliser un paramètre de config (cache_pool: cache.app) pour permettre à l'app de pointer ailleurs.
  4. Public services dans un bundle — par défaut tout devrait être privé. Public uniquement pour les services que l'app va explicitement $container->get() (rare).
  5. Oublier _defaults + autowire: true dans services.yaml du bundle — services non auto-câblés, autoconfigure off. Frustrant à debug.
  6. Configuration::getConfigTreeBuilder() sans normalisation — l'utilisateur passe une string là où tu attends un array → erreur cryptique. Utilise ->beforeNormalization()->ifString()->then(...).
  7. Routes auto-chargées sans namespace — un bundle qui expose des controllers doit publier ses routes via Resources/config/routes.yaml ou via attribute scan explicitement déclaré dans la config Routing du projet utilisateur.
  8. Ne pas supporter le mode dev/prod séparé — si ton bundle utilise des services lourds en dev (profiler), expose une option acme_blog.profiling: '%kernel.debug%'.

🧪 Testing

php
// vendor/acme/blog-bundle/tests/Bundle/AcmeBlogBundleTest.php
<?php
namespace Acme\BlogBundle\Tests\Bundle;

use Acme\BlogBundle\AcmeBlogBundle;
use Acme\BlogBundle\Service\FeedFetcher;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel;

final class AcmeBlogBundleTest extends TestCase
{
    public function testBundleBootsInTestKernel(): void
    {
        $kernel = new class('test', true) extends Kernel {
            public function registerBundles(): iterable
            {
                yield new \Symfony\Bundle\FrameworkBundle\FrameworkBundle();
                yield new AcmeBlogBundle();
            }
            public function loadRoutes($loader): void {}
            protected function configureContainer(ContainerBuilder $c, $loader): void
            {
                $c->loadFromExtension('framework', ['test' => true, 'http_method_override' => false]);
                $c->loadFromExtension('acme_blog', ['items_per_page' => 5]);
            }
        };
        $kernel->boot();
        $container = $kernel->getContainer();

        self::assertSame(5, $container->getParameter('acme_blog.items_per_page'));
        self::assertTrue($container->has(FeedFetcher::class));
    }
}

Tester la config tree d'un AbstractBundle directement (sans booter le kernel). AbstractBundle n'implémente pas ConfigurationInterface lui-même : il expose un ConfigurationInterface interne via getConfiguration(). On récupère ce nœud puis on le passe au Processor :

php
use Acme\BlogBundle\AcmeBlogBundle;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final class AcmeBlogConfigurationTest extends TestCase
{
    public function testDefaultsAndOverride(): void
    {
        $bundle = new AcmeBlogBundle();

        // getConfiguration() construit le ConfigurationInterface à partir de configure().
        $configuration = $bundle->getConfiguration([], new ContainerBuilder());
        self::assertNotNull($configuration);

        $processor = new Processor();

        // Defaults appliqués quand l'app ne passe rien.
        $defaults = $processor->processConfiguration($configuration, [[]]);
        self::assertSame(20, $defaults['items_per_page']);

        // Override + validation des bornes.
        $config = $processor->processConfiguration($configuration, [['items_per_page' => 50]]);
        self::assertSame(50, $config['items_per_page']);
    }

    public function testItemsPerPageRejectsOutOfRange(): void
    {
        $bundle = new AcmeBlogBundle();
        $configuration = $bundle->getConfiguration([], new ContainerBuilder());

        $this->expectException(\Symfony\Component\Config\Definition\Exception\InvalidConfigurationException::class);
        (new Processor())->processConfiguration($configuration, [['items_per_page' => 999]]);
    }
}

Note : getConfiguration() est public sur AbstractBundle à partir de Symfony 6.1. Sur 5.4, elle existe mais la signature peut différer ; le test reste valable si tu cibles 6.4+/7.x.

🎬 Cas d'usage concrets

Scénario 1 — Cabinet juridique : LegalSearchBundle factorisé entre 3 apps

Contexte : un cabinet d'avocats d'affaires parisien (~120 collaborateurs) maintient 3 apps Symfony distinctes : portail client, intranet collaborateurs, plateforme de génération d'actes. Les trois apps doivent interroger la base de jurisprudence Légifrance et Doctrine.fr via une couche unifiée (cache, retry, anonymisation des données client lors d'une recherche).

L'équipe extrait un LegalSearchBundle interne, hébergé sur Gitea privé et installé via Composer en VCS repository. Il expose : un service LegalSearchClient, des SearchProviderInterface (Légifrance, Doctrine.fr, JurisData) auto-taggés via _instanceof, une config sémantique legal_search.providers.legifrance.api_key, et un legal_search:reindex command. Chaque projet l'inclut, déclare ses clés API dans .env.local, et bénéficie automatiquement des fixes et nouveaux providers.

Résultat : suppression d'environ 1 400 lignes dupliquées entre les 3 apps, et un bug critique sur l'anonymisation a été corrigé une seule fois (vs 3 patchs à synchroniser).

Scénario 2 — FinTech (Lemonway-like) : PaymentGatewayBundle extensible

Contexte : éditeur d'une plateforme d'encaissement pour marketplaces (escrow, KYB, virements SEPA, cagnottes). Le cœur "encaissement" est utilisé par 4 produits différents (marketplaces clients, SaaS facturation, app mobile B2C, white-label). Chaque produit a un sous-ensemble de moyens de paiement.

L'équipe construit un PaymentGatewayBundle (extends AbstractBundle) qui définit l'interface PaymentGatewayInterface, expose une config sémantique typée (paymentGateways.stripe.publicKey), et fournit un compiler pass qui agrège tous les gateways tagués. Les implémentations concrètes (StripeGateway, SepaXmlGateway, MangopayGateway) sont dans des sub-bundles ou des packages séparés, chacun déclarant requires: ["acme/payment-gateway-bundle"]. Activation par produit : le SaaS facturation n'inclut que stripe + sepa, l'app mobile inclut tout.

Effet net : l'équipe core itère sur l'interface, les équipes produit composent leurs gateways. Aucun risque qu'un produit "voit" du code paiement non pertinent (surface compliance réduite).

Scénario 3 — Intégration Pennylane via PennylaneBundle packageable

Contexte : ESN française qui fait des sites e-commerce sur-mesure. 18 clients ont demandé une synchronisation des factures et des comptes vers Pennylane (compta cloud française leader). Au lieu de copier-coller le client API à chaque mission, l'ESN crée un PennylaneBundle open source publié sur Packagist.

Le bundle expose : PennylaneClient (autowiré), un MessageHandlerInterface qui consume des SyncInvoiceMessage depuis Messenger, des extension points (InvoicePayloadProviderInterface) pour mapper les entités client → format Pennylane. Une config sémantique permet de configurer OAuth client_id/secret par tenant. Une pennylane:sync:invoices command CLI permet le backfill.

Bénéfice mesuré : un nouveau client e-commerce est intégré à Pennylane en 2 jours (vs ~10 jours auparavant), et le bundle a recueilli ~300 stars Packagist et des PRs externes qui ajoutent le support des notes de frais.

🛠️ Exemple end-to-end

Use case : créer un bundle interne AcmeFeatureFlagBundle réutilisable. Il fournit un service FeatureFlags qui lit l'état des features depuis Redis avec fallback sur YAML, un controller debug /admin/features, et une config sémantique typée. Sera installé dans 5 apps du groupe.

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

namespace Acme\FeatureFlagBundle;

use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;

final class AcmeFeatureFlagBundle extends AbstractBundle
{
    protected string $extensionAlias = 'acme_feature_flag';

    public function configure(DefinitionConfigurator $definition): void
    {
        $definition->rootNode()
            ->children()
                ->scalarNode('redis_dsn')
                    ->isRequired()
                    ->info('Redis DSN where flag states live')
                ->end()
                ->scalarNode('cache_ttl')->defaultValue(60)->end()
                ->arrayNode('defaults')
                    ->info('Static fallback flags if Redis is unreachable')
                    ->useAttributeAsKey('name')
                    ->scalarPrototype()->end()
                ->end()
                ->booleanNode('expose_debug_controller')->defaultFalse()->end()
            ->end();
    }

    public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
    {
        $container->import('../config/services.php');

        // Le DSN alimente la *fabrique* du client Redis…
        $builder->getDefinition('acme_feature_flag.redis')
            ->setArgument('$dsn', $config['redis_dsn']);

        // …et les options métier alimentent le service de flags.
        $builder->getDefinition('acme_feature_flag.client')
            ->setArgument('$cacheTtl', $config['cache_ttl'])
            ->setArgument('$defaults', $config['defaults']);

        if ($config['expose_debug_controller']) {
            $container->import('../config/routes/debug.php');
        }
    }
}
php
// src/FeatureFlags.php
<?php
declare(strict_types=1);

namespace Acme\FeatureFlagBundle;

use Predis\Client as RedisClient;
use Psr\Log\LoggerInterface;

final class FeatureFlags
{
    private ?array $cachedFlags = null;
    private int $cachedAt = 0;

    /**
     * @param array<string,string> $defaults
     */
    public function __construct(
        private readonly RedisClient $redis,
        private readonly LoggerInterface $logger,
        private readonly int $cacheTtl,
        private readonly array $defaults,
    ) {}

    public function isEnabled(string $flag, ?string $tenantId = null): bool
    {
        $key = $tenantId !== null ? "feature:{$tenantId}:{$flag}" : "feature:global:{$flag}";

        try {
            if ($this->cachedFlags === null || (time() - $this->cachedAt) > $this->cacheTtl) {
                $this->cachedFlags = $this->redis->hgetall('features:all') ?: [];
                $this->cachedAt = time();
            }
            if (isset($this->cachedFlags[$key])) {
                return $this->cachedFlags[$key] === '1';
            }
        } catch (\Throwable $e) {
            $this->logger->warning('FeatureFlags Redis unreachable, using defaults', ['exception' => $e]);
        }

        return ($this->defaults[$flag] ?? '0') === '1';
    }
}
php
// config/services.php (within bundle)
<?php
use Acme\FeatureFlagBundle\FeatureFlags;
use Acme\FeatureFlagBundle\RedisClientFactory;
use Predis\Client as RedisClient;

use function Symfony\Component\DependencyInjection\Loader\Configurator\service;

return static function (\Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator $container): void {
    $services = $container->services()
        ->defaults()
            ->autowire()
            ->autoconfigure()
            ->private();

    // Le client Redis est produit par une fabrique : $dsn est surchargé
    // par loadExtension() à partir de la config sémantique de l'app.
    $services->set('acme_feature_flag.redis', RedisClient::class)
        ->factory([RedisClientFactory::class, 'create'])
        ->arg('$dsn', null); // injecté par loadExtension()

    $services->set('acme_feature_flag.client', FeatureFlags::class)
        ->arg('$redis', service('acme_feature_flag.redis'))
        ->arg('$cacheTtl', null)   // injecté par loadExtension()
        ->arg('$defaults', []);    // injecté par loadExtension()

    // Alias public typé : l'app peut autowirer FeatureFlags directement.
    $services->alias(FeatureFlags::class, 'acme_feature_flag.client')->public();
};
php
// src/RedisClientFactory.php
<?php
declare(strict_types=1);

namespace Acme\FeatureFlagBundle;

use Predis\Client as RedisClient;

final class RedisClientFactory
{
    public static function create(string $dsn): RedisClient
    {
        // Predis accepte un DSN brut (redis://host:port/db) en argument unique.
        return new RedisClient($dsn);
    }
}

Pourquoi une fabrique plutôt qu'un arg(0, '%env(...)%') ? Parce que la source de vérité du DSN est la config sémantique du bundle (acme_feature_flag.redis_dsn), pas une env var au nom imposé. La fabrique découple le bundle de la convention de nommage des env vars de l'app : l'utilisateur écrit redis_dsn: '%env(FEATURE_REDIS_DSN)%' avec le nom qu'il veut, et loadExtension() route la valeur résolue vers $dsn. C'est la différence entre un bundle qui impose ses conventions et un bundle qui s'adapte à l'app hôte.

yaml
# app consumer config/packages/acme_feature_flag.yaml
acme_feature_flag:
    redis_dsn: '%env(string:FEATURE_REDIS_DSN)%'
    cache_ttl: 30
    expose_debug_controller: '%env(bool:APP_DEBUG)%'
    defaults:
        new_checkout_ui: '1'
        ai_recommendations: '0'

Le bundle est versionné indépendamment (acme/feature-flag-bundle:^1.2). Bumper la version dans une app applique les nouvelles features ; rollback = composer require acme/feature-flag-bundle:1.1.*. Les 5 apps consomment exactement la même implémentation, les bugs sont corrigés une fois.

🧩 Compiler pass — le vrai super-pouvoir d'un bundle

La config sémantique, n'importe quelle lib Composer peut la singer. Ce qu'un bundle apporte que personne d'autre ne peut faire proprement, c'est l'accès au container en cours de compilation : agréger des services taggés, réécrire des définitions, ajouter du tag conditionnel. C'est le pattern plugin registry.

php
// src/DependencyInjection/Compiler/GatewayPass.php
<?php
declare(strict_types=1);

namespace Acme\PaymentGatewayBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;

final class GatewayPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        if (!$container->has('acme_payment.registry')) {
            return; // bundle désactivé : on ne casse rien
        }

        $registry = $container->findDefinition('acme_payment.registry');
        $gateways = [];

        // Trie par priorité (descendante) pour un ordre déterministe.
        $tagged = $container->findTaggedServiceIds('acme_payment.gateway');
        uasort($tagged, static fn (array $a, array $b) => ($b[0]['priority'] ?? 0) <=> ($a[0]['priority'] ?? 0));

        foreach ($tagged as $id => $tags) {
            foreach ($tags as $attributes) {
                $code = $attributes['code']
                    ?? throw new InvalidArgumentException(sprintf(
                        'Le service "%s" doit définir l\'attribut "code" sur le tag "acme_payment.gateway".',
                        $id,
                    ));

                if (isset($gateways[$code])) {
                    throw new InvalidArgumentException(sprintf('Code de gateway "%s" dupliqué.', $code));
                }

                $gateways[$code] = new Reference($id);
            }
        }

        // Indexe le service locator pour un lazy-loading par code, pas un array eager.
        $registry->setArgument('$gateways', $gateways);
    }
}
php
// Dans le bundle : enregistrer la pass
public function build(ContainerBuilder $container): void
{
    parent::build($container);
    $container->addCompilerPass(new GatewayPass());
}

Côté implémentation concrète, on évite de baliser à la main : l'autoconfiguration par interface fait le tag pour toi.

php
// loadExtension() — chaque service implémentant l'interface est taggé.
$builder->registerForAutoconfiguration(PaymentGatewayInterface::class)
    ->addTag('acme_payment.gateway');
php
// L'app n'a plus qu'à implémenter l'interface : zéro YAML.
final class StripeGateway implements PaymentGatewayInterface
{
    public function code(): string { return 'stripe'; }
    // ...
}

Détail staff : tag manuel vs registerForAutoconfiguration. Le second est plus ergonomique mais le code du tag (l'attribut) n'est plus disponible — d'où le pattern « la priorité/le code vivent sur l'objet (code() method) et le compiler pass lit l'index depuis le service, pas depuis le tag ». Pour garder l'attribut de tag avec l'autoconfig, on passe par un attribut PHP custom (#[AsPaymentGateway(code: 'stripe')]) + registerAttributeForAutoconfiguration() (Symfony 6.3+), qui mappe l'attribut PHP vers les attributs de tag.

php
// Symfony 6.3+ : attribut PHP -> tag attributes, le best-in-class.
$builder->registerAttributeForAutoconfiguration(
    AsPaymentGateway::class,
    static function (ChildDefinition $definition, AsPaymentGateway $attribute): void {
        $definition->addTag('acme_payment.gateway', ['code' => $attribute->code, 'priority' => $attribute->priority]);
    },
);

🗺️ Arbre de décision — bundle, package, ou rien

Tu as…ChoixPourquoi
Du code utile à un seul projetsrc/ (App)Aucun packaging, zéro overhead de versioning
Une lib pure (pas de services Symfony)Package Composer normalPas besoin du container ; testable hors Symfony, réutilisable hors Symfony
Une lib + quelques services sans configPackage + Bundle minimal ou services.php importableSi pas de tag/compiler-pass, un simple fichier de config importable suffit souvent
Config sémantique + tags/compiler-pass/listenersBundle (AbstractBundle)Seul un bundle accède au container en compilation et offre une config tree standard
Tu veux « organiser par features »Modules PSR-4 App\Module\*Les bundles ne sont pas un outil d'architecture interne depuis SF4

Le test décisif : écris la phrase « j'ai besoin de _____ ». Si le blanc se remplit avec « tags DI agrégés », « config tree validée », « prepend d'un autre bundle », « auto-découverte de services à l'install » → bundle. Sinon → package ou src/.

🔭 Production — versioning, observabilité, sécurité d'un bundle partagé

  • SemVer strict, et il s'applique à ta config tree. Renommer une clé de config (items_per_pagepageSize) ou changer un défaut = breaking change. Tes consommateurs ont leur YAML figé. Déprécie via ->setDeprecated('acme/blog-bundle', '1.4', 'Use "page_size" instead.') sur le nœud, garde les deux le temps d'un major.
  • config:dump-reference est ta doc vivante. php bin/console config:dump-reference acme_blog génère la doc de config à jour. En CI, snapshot-teste cette sortie : un diff non voulu = breaking change détecté avant release.
  • Surface publique minimale = surface de support minimale. Tout service public ou toute classe non-@internal devient un contrat. Marque @internal agressivement ; n'expose que des interfaces et le service principal. Un consommateur qui type-hint une classe interne te bloquera au prochain refactor.
  • Observabilité : un bundle qui fait des I/O (Redis, HTTP) doit injecter un LoggerInterface (channel dédié via monolog.logger tag) et idéalement décorer ses clients pour émettre des métriques. Ne error_log() jamais en dur — tu pollues les logs de l'app hôte sans contexte.
  • Sécurité / supply chain : un bundle interne sur Gitea/Packagist privé est une dépendance de confiance exécutée dans tous tes apps. Pin les versions (^1.2 pas *), active composer audit en CI, signe tes tags. Une compromise du bundle = RCE sur 5 apps.
  • Compat multi-versions = coût réel. Supporter ^5.4 || ^7.0 veut dire matrice CI ×3 et éviter toute API introduite après 5.4. Si tu ne sers qu'une app interne sur une seule version SF, ne supporte qu'elle — la compat large est un coût qu'on paie pour l'open source, pas pour l'interne.

🔁 Quand utiliser / éviter

Écrire un bundle :

  • Le code sera utilisé dans ≥ 2 projets (interne ou open source).
  • Il s'intègre profondément à Symfony (services taggés, listeners, twig functions, config sémantique).
  • Tu as besoin d'un mécanisme de config standard pour l'utilisateur.

NE PAS écrire un bundle :

  • C'est juste une lib PHP utilisable seule → fais une librairie Composer normale. Pas besoin de wrapping bundle.
  • Le code est spécifique à un seul projet → reste dans App\.
  • Tu veux "organiser" ton app par features → utilise des modules PSR-4 sous App\Module\*\ plutôt que des bundles.

Indice : si tu peux remplacer "Bundle" par "Package", c'est probablement une lib pure ; si tu as besoin de tags DI, event listeners auto-enregistrés et config tree, c'est un bundle.

🪜 Approfondissement — pattern bundle "legacy" (avant AbstractBundle)

Pour comprendre le code des bundles existants, voici le pattern historique encore très répandu :

php
// vendor/acme/blog-bundle/src/AcmeBlogBundle.php (LEGACY)
namespace Acme\BlogBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

final class AcmeBlogBundle extends Bundle
{
    // Pas de configure(), juste l'enveloppe
    // Symfony charge automatiquement DependencyInjection/AcmeBlogExtension
}
php
// src/DependencyInjection/AcmeBlogExtension.php
namespace Acme\BlogBundle\DependencyInjection;

use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

final class AcmeBlogExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container): void
    {
        $config = $this->processConfiguration(new Configuration(), $configs);

        $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../../config'));
        $loader->load('services.yaml');

        $container->setParameter('acme_blog.items_per_page', $config['items_per_page']);
    }
}
php
// src/DependencyInjection/Configuration.php
namespace Acme\BlogBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

final class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder(): TreeBuilder
    {
        $builder = new TreeBuilder('acme_blog');
        $builder->getRootNode()
            ->children()
                ->integerNode('items_per_page')->defaultValue(20)->end()
            ->end();
        return $builder;
    }
}

C'est 3 fichiers vs 1 fichier avec AbstractBundle. À éviter en nouveau code, mais à savoir lire dans tout projet > 2 ans.

🪜 Bundle qui prepend la config d'un autre bundle

Cas d'usage : ton bundle a besoin que Doctrine soit configuré avec une certaine option, ou veut ajouter un route loader. Implémente PrependExtensionInterface :

php
// vendor/acme/blog-bundle/src/AcmeBlogBundle.php
public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
{
    $container->extension('framework', [
        'translator' => [
            'paths' => ['%kernel.project_dir%/vendor/acme/blog-bundle/translations'],
        ],
    ]);
    $container->extension('twig', [
        'paths' => [
            '%kernel.project_dir%/vendor/acme/blog-bundle/templates' => 'AcmeBlog',
        ],
    ]);
}

Cette config est mergée avec celle de l'app avant le chargement principal — l'app peut donc encore override.

🪜 Tests d'un bundle multi-versions Symfony

Pour qu'un bundle marche sur Symfony 5.4 / 6.x / 7.x simultanément :

json
// composer.json du bundle
{
    "require": {
        "php": ">=8.1",
        "symfony/framework-bundle": "^5.4 || ^6.4 || ^7.0",
        "symfony/config": "^5.4 || ^6.4 || ^7.0"
    },
    "require-dev": {
        "phpunit/phpunit": "^9.6 || ^10.0",
        "symfony/phpunit-bridge": "^7.0"
    }
}

CI matrix : runner avec composer require symfony/framework-bundle:^5.4 puis ^6.4 puis ^7.0. Tester contre 3 versions.

🏋️ Exercices

Progression : d'abord tu écris un bundle, puis tu le rends production-grade, enfin tu casses puis répares. Chaque exercice doit tourner réellement (composer install dans un repo bundle + une app de test).

1. Le bundle minimal qui boote — AbstractBundle en un fichier

Objectif : créer AcmeHelloBundle exposant un service Greeter et une config sémantique acme_hello.greeting (défaut 'Bonjour'), vérifié par un test qui boote un kernel.

Indice / Solution

Un seul fichier AcmeHelloBundle extends AbstractBundle : configure() déclare scalarNode('greeting')->defaultValue('Bonjour'), loadExtension() fait $container->import('../config/services.php') puis $builder->getDefinition('acme_hello.greeter')->setArgument('$greeting', $config['greeting']). Réutilise le testBundleBootsInTestKernel de la section Testing : assert que $container->get(Greeter::class)->greet('Ada') renvoie 'Bonjour, Ada'. Piège classique : oublier _defaults: autowire → le service n'est pas trouvé.

2. Plugin registry via compiler pass + autoconfiguration par interface

Objectif : exposer un NotifierRegistry qui agrège tous les services implémentant ChannelInterface (email, sms, slack), résolus par code(), avec ordre déterministe par priorité.

Indice / Solution

registerForAutoconfiguration(ChannelInterface::class)->addTag('acme_notify.channel') dans loadExtension(). Un CompilerPassInterface enregistré dans build() qui findTaggedServiceIds, uasort par priorité, et setArgument('$channels', $refs) sur le registry. Pour garder l'attribut priority avec l'autoconfig, passe par un attribut PHP #[AsChannel(priority: 10)] + registerAttributeForAutoconfiguration() (6.3+). Test : enregistre 3 channels, assert l'ordre de $registry->all(). Piège : un registry eager (array d'instances) instancie tout au boot — préfère un ServiceLocator pour le lazy-loading par code.

3. Prepend transparent + config dépréciée

Objectif : ton bundle a besoin que Twig connaisse son namespace de templates ET il doit déprécier proprement l'ancienne clé items_per_page au profit de page_size.

Indice / Solution

prependExtension() injecte twig.paths (cf. section prepend) — vérifie que l'app peut toujours override en testant que sa propre config gagne. Pour la déprec : garde les deux nœuds, mets ->setDeprecated('acme/blog-bundle', '1.4', 'Use "page_size" instead.') sur items_per_page, et dans loadExtension() fais $pageSize = $config['page_size'] ?? $config['items_per_page'] ?? 20. Snapshot-teste config:dump-reference pour prouver que la déprec apparaît.

4. Production-grade — résilience Redis + observabilité

Objectif : durcir le FeatureFlags end-to-end pour qu'une panne Redis ne fasse jamais tomber l'app hôte et soit observable.

Indice / Solution

Le try/catch autour de hgetall + fallback sur $defaults est déjà là — ajoute : (1) un circuit breaker simple (après N échecs, on saute Redis pendant T secondes au lieu de retenter à chaque requête, sinon chaque appel paie le timeout de connexion) ; (2) un channel de log dédié (monolog.logger tag, channel feature_flags) pour ne pas noyer les logs de l'app ; (3) une métrique feature_flags.redis.fallback incrémentée. Test du failure mode : injecte un RedisClient mocké qui throw, assert que isEnabled() renvoie le défaut et que le breaker s'ouvre après le seuil.

5. Break-then-fix — la fuite de service public

Objectif : on te donne un bundle où config:dump-reference marche mais l'app consommatrice plante au build avec Service "acme_blog.internal_cache" not found après une montée de version mineure. Diagnostique et corrige.

Indice / Solution

Cause typique : l'app type-hintait acme_blog.internal_cache (service jadis public, passé private en 1.3 — un vrai breaking change qui aurait dû être un major). Fix correct : ne pas re-publier le service interne ; à la place exposer une interface publique stable (CacheReaderInterface) aliasée, marquer l'ancien service @internal, et documenter la migration. Variante du bug : un removeIds/optimisation du container a inliné un service privé non-référencé — fix = garder une Reference réelle ou marquer le service comme non-inlinable si l'app en a légitimement besoin (et alors le repasser public via alias public typé, jamais via l'ID interne).

🎤 En entretien

Q : Pourquoi ne wrappe-t-on plus son application dans un bundle depuis Symfony 4 ? Parce que Flex + l'autoconfiguration rendent le wrapping inutile et coûteux : config dupliquée, namespace verbeux, et conflit avec les recipes. Le bundle est désormais une unité de partage entre projets, pas une unité d'organisation interne — pour ça on utilise des modules PSR-4 App\Module\*.

Q : Bundle ou simple package Composer — comment tu tranches ? Si le code a besoin du container en compilation (tags agrégés, compiler pass, listeners auto-enregistrés) ou d'une config tree sémantique standard, c'est un bundle ; sinon c'est une lib pure et le wrapping bundle n'apporte que du bruit. Test mental : « j'ai besoin de tags DI / config validée / prepend » → bundle ; sinon → package.

Q : Qu'apporte AbstractBundle par rapport au triplet Bundle + Extension + Configuration ? Il fusionne les trois en un fichier : configure(DefinitionConfigurator) remplace la classe Configuration, loadExtension(array $config, ...) reçoit la config déjà processée avec les défauts appliqués et remplace l'Extension. Moins de boilerplate, moins de surface d'erreur, et l'extensionAlias est déduit ou déclaré en une ligne. Le pattern legacy reste 100% supporté et il faut savoir le lire dans les bundles anciens.

Q : Comment un compiler pass agrège-t-il des implémentations de manière déterministe et safe ? Via registerForAutoconfiguration(Interface::class)->addTag(...) (ou un attribut PHP + registerAttributeForAutoconfiguration en 6.3+) pour tagger sans YAML, puis un CompilerPassInterface qui findTaggedServiceIds, trie par priorité (uasort) pour un ordre stable, rejette les codes dupliqués, et injecte un ServiceLocator plutôt qu'un array eager pour ne pas tout instancier au boot. Toujours guarder par if (!$container->has(...)) return; pour ne pas casser quand le bundle est désactivé.

🔗 Liens

Bibliothèque tech perso — Achref