Symfony Bundle Extension Class and Compiler Pass Registration In Depth
AI generated
SF
{ }
Symfony · Bundle Extension · Compiler Pass · DI
Symfony Bundle Extension and Compiler Passes
How a bundle loads services and manipulates the container on purpose

The bundle extension is the mechanism through which a Symfony bundle registers its services in the host container. Compiler passes go one step further and hook directly into the ContainerBuilder after all bundles have loaded, to collect tagged services or modify definitions. This article explains both mechanisms and how they work together.

18 min read ExtensionInterface · ContainerBuilder · Compiler Pass Symfony 7.x · symfony/dependency-injection

1. Extension and compiler pass in the container lifecycle

A Symfony bundle extension and a compiler pass solve two different, but closely related tasks in the lifecycle of the dependency injection container. The extension loads service definitions and evaluates the validated bundle configuration before the container is compiled. The compiler pass runs afterwards, once every bundle has already run its extension, and can therefore read and modify definitions from other bundles, which the extension alone cannot do.

This ordering matters: while each extension only knows its own service definitions, a compiler pass has access to the full ContainerBuilder with every service registered up to that point. That makes compiler passes the right tool for tasks such as collecting every service carrying a specific tag, regardless of which bundle it came from, for example all event handlers that marked themselves as acme_audit.subscriber.

For a Symfony bundle designed to be extensible itself, this interplay is central: the extension defines the core functionality, the compiler pass lets other bundles or the host project hook into that functionality through a tag, without the bundle needing to know the concrete extensions at development time.

2. The extension class: load() in detail

An extension class implements Symfony\Component\DependencyInjection\Extension\ExtensionInterface, in practice usually through the abstract base class Extension, which already provides sensible default implementations for getAlias(). The central method is load(array $configs, ContainerBuilder $container): it receives the still unprocessed configuration arrays from every source, plus the host project's ContainerBuilder into which service definitions are registered.

The first step inside load() is almost always a call to processConfiguration(), which validates the raw configuration arrays against the Configuration Tree Builder and merges them into a single, type safe array. After that, the method loads the actual service definitions, usually through a PhpFileLoader or YamlFileLoader, and sets container parameters from the validated configuration values so services can reference them through a constructor argument.


// src/DependencyInjection/AcmeAuditExtension.php
declare(strict_types=1);

namespace Acme\AuditBundle\DependencyInjection;

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

/**
 * Loads services and processes configuration for the audit bundle.
 */
final class AcmeAuditExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container): void
    {
        $configuration = new Configuration();
        $config = $this->processConfiguration($configuration, $configs);

        $container->setParameter('acme_audit.table_name', $config['table_name']);
        $container->setParameter('acme_audit.retention_days', $config['retention_days']);

        $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
        $loader->load('services.php');
    }
}

3. Loading service definitions: PHP vs. YAML

For the actual service registration, a Symfony bundle extension has several loader classes available: PhpFileLoader for a PHP configuration file with the modern ContainerConfigurator syntax, YamlFileLoader for classic YAML definitions and XmlFileLoader for XML, which is still commonly found in official Symfony bundles because XML allows stable schema validation. For new, internal bundles the PHP variant is usually the most practical choice, because it benefits from IDE support, autocompletion and refactoring tools.

Inside the services.php file, the same constructs are available as in application configuration: services()->set() for individual definitions, autowire() and autoconfigure() for automatic wiring, though scoped to the bundle's own namespace. A common best practice: only mark services public that are actually meant to be referenced from the outside, all internal implementation details stay private and are therefore not directly retrievable from the host container.


// config/services.php inside the bundle
declare(strict_types=1);

use Acme\AuditBundle\EventListener\AuditLogListener;
use Acme\AuditBundle\Service\AuditLogger;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $configurator): void {
    $services = $configurator->services()
        ->defaults()
        ->autowire()
        ->autoconfigure();

    // Public: consuming applications may inject this service directly
    $services->set(AuditLogger::class)
        ->public()
        ->arg('$tableName', '%acme_audit.table_name%');

    // Private: only used internally through event tagging
    $services->set(AuditLogListener::class)
        ->tag('kernel.event_subscriber');
};

4. Writing a custom compiler pass

A compiler pass implements Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface with exactly one method: process(ContainerBuilder $container). Inside this method there is full access to every service definition already registered, regardless of which bundle it came from. A typical use case in a Symfony bundle: collect all services carrying a specific tag and inject them as arguments into a central collector service, such as a dispatcher or a registry.

The compiler pass should be written defensively, because it runs at a point where it is not guaranteed that every expected service exists. A has() check before every getDefinition() call prevents the container build from failing with a hard to trace exception, in case a host project has disabled an expected service, for example through a configuration change.


// src/DependencyInjection/Compiler/AuditSubscriberPass.php
declare(strict_types=1);

namespace Acme\AuditBundle\DependencyInjection\Compiler;

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

/**
 * Collects all services tagged as acme_audit.subscriber and injects
 * them into the central audit dispatcher.
 */
final class AuditSubscriberPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        if (!$container->has('acme_audit.dispatcher')) {
            return;
        }

        $dispatcherDefinition = $container->getDefinition('acme_audit.dispatcher');
        $taggedServices = $container->findTaggedServiceIds('acme_audit.subscriber');

        foreach ($taggedServices as $id => $tags) {
            $dispatcherDefinition->addMethodCall('addSubscriber', [new Reference($id)]);
        }
    }
}

5. Registering a compiler pass in the bundle class

A compiler pass is not loaded like a normal service definition through the extension, it is registered in the build() method of the bundle class through $container->addCompilerPass(). This separation is deliberate: build() runs for all bundles before the extensions are actually loaded, so a compiler pass can be registered independently of whether and how the extension was configured.

Important with multiple compiler passes in the same bundle: each pass should have exactly one responsibility, instead of bundling several unrelated tasks into a single process() method. This makes both testing individual passes and later extending them easier, once a bundle grows and new cross cutting concerns are added.

6. Controlling PassConfig type and priority

addCompilerPass() accepts two additional parameters besides the pass object itself: the PassConfig type and a priority. The type determines in which of the predefined phases the pass runs, for example PassConfig::TYPE_BEFORE_OPTIMIZATION for passes that should run before unused private services are removed, or PassConfig::TYPE_AFTER_REMOVING for passes that should only run after that cleanup. This phase choice decides whether a compiler pass can still see private, potentially removable services or not.

The priority as an integer value controls the order within the same phase: a higher value runs earlier. This becomes relevant when several compiler passes from different bundles populate the same collector service and a specific order, for example validation handlers before transformation handlers, is required by business logic.


// src/AcmeAuditBundle.php — registering with an explicit pass type and priority
use Symfony\Component\DependencyInjection\Compiler\PassConfig;

public function build(ContainerBuilder $container): void
{
    parent::build($container);

    $container->addCompilerPass(
        new AuditSubscriberPass(),
        PassConfig::TYPE_BEFORE_OPTIMIZATION,
        priority: 10
    );
}

7. Collecting tagged services: findTaggedServiceIds()

findTaggedServiceIds() is the central method a compiler pass uses to find every service that registered itself as an extension through a tag. It returns an associative array whose keys are the service ids and whose values contain the attributes attached to the tag. These attributes allow fine grained control, such as a priority per subscriber, without the compiler pass itself needing to know how many subscribers exist or which bundle they came from.


# Inspect which services carry a given tag after the container has compiled
bin/console debug:container --tag=acme_audit.subscriber

# Output shows every tagged service id, useful to verify a compiler pass
# actually picked up all expected subscribers before debugging further

For Symfony bundles meant to be extensible themselves, this combination of tag and compiler pass is the established mechanism: the bundle defines a tag name as a public convention, such as acme_audit.subscriber, and any other bundle or the host project can mark its own services with this tag, without ever needing to change the audit bundle itself. Autoconfigure can even assign this tag automatically based on an interface, making manual tagging unnecessary in many cases.

8. Common pitfalls with extensions and compiler passes

The most common mistake: a compiler pass accesses a service before it is confirmed to exist, resulting in a ServiceNotFoundException that is hard for bundle consumers to trace. A second pitfall involves phase selection: if a compiler pass is registered in the wrong PassConfig phase, it might accidentally see services that should already have been removed, or conversely miss services that only appear later in the process.

A third mistake is mixing responsibilities: configuration processing belongs in the extension, cross container manipulation belongs in the compiler pass. Trying to read definitions from other bundles already inside load() often fails or produces incomplete results, because not every extension has run at that point yet. Keeping this ordering clean is the most important principle when working with the extension class and compiler passes.

9. Extension vs. compiler pass compared

Both mechanisms complement each other, but they have clearly different use cases within a Symfony bundle.

Criterion Extension Compiler pass
Execution point While loading the respective bundle itself After all bundle extensions have loaded
Visible services Only its own All registered services
Main task Loading service definitions, evaluating configuration Reading and modifying other bundles' definitions
Typical use case Setting parameters from the configuration tree Collecting and wiring tagged services
Registration Automatic through naming convention Manual via addCompilerPass() in build()

Anyone using both mechanisms correctly builds a Symfony bundle that is extensible itself: the extension delivers the core functionality, the compiler pass opens extension points for other bundles and the host project, without creating a hard dependency on concrete classes.

Mironsoft

Symfony bundle architecture, extension classes and compiler passes

Need your internal bundle to become extensible?

We design extension classes and compiler passes for your Symfony bundles, with clean tag based extension points, the correct PassConfig phase and defensive error handling during the container build.

Extension design

Clean separation of configuration, parameters and service definitions

Compiler pass development

Tag based extension points for other bundles and the host project

Debugging

Analyzing container compilation and resolving ordering issues

10. Summary

A Symfony bundle extension loads service definitions and evaluates bundle configuration through processConfiguration(), with access only to its own definitions. A compiler pass runs afterwards, once every extension has already loaded, and can therefore read and modify the full ContainerBuilder, for example using findTaggedServiceIds() to collect tagged services and inject them into a central service.

A compiler pass is registered via addCompilerPass() in the bundle class's build() method, with optional control over PassConfig phase and priority. Defensive checks with has() before every getDefinition() call prevent hard to trace errors when expected services are missing. Anyone who cleanly separates extension and compiler pass builds Symfony bundles that are extensible themselves, without a hard dependency on concrete implementations of other bundles.

Symfony Bundle Extension and Compiler Passes — At a glance

Extension class

load() processes configuration and loads service definitions, with access only to its own services.

Compiler pass

process() runs after every extension, with access to the full ContainerBuilder.

Registration

addCompilerPass() inside the bundle class's build(), with optional phase and priority.

Extensibility

A tag convention plus findTaggedServiceIds() lets other bundles hook in.

11. FAQ: Symfony Bundle Extension Class and Compiler Pass Registration

1Extension vs. compiler pass?
Extension loads only its own services, compiler pass runs afterwards with full container access.
2Where is a compiler pass registered?
In build() of the bundle class via addCompilerPass(), not in the extension.
3Why no foreign definitions in the extension?
Other extensions may not have run yet at that point, only the compiler pass guarantees that.
4What does findTaggedServiceIds() do?
Returns every service id with a given tag plus tag attributes, standard way to build extension points.
5What is TYPE_BEFORE_OPTIMIZATION for?
Runs the pass before unused private services are removed.
6Prevent a ServiceNotFoundException?
has() check before every getDefinition() so the pass terminates cleanly instead of crashing.
7Multiple compiler passes possible?
Yes, common practice, each pass should have one clearly scoped responsibility.
8Own configuration class required?
Not strictly, but recommended as soon as configuration is more than trivial.
9Control order of multiple passes?
Via the priority parameter of addCompilerPass(), higher value runs earlier.
10Advantage of PHP definitions over YAML?
IDE support, type checking and refactoring work directly since it is PHP code.