Symfony Kernel & Custom Bundles in 2026: still worth it?
AI generated
SF
{ }
Symfony · Kernel · Bundles · DI · PHP 8.4
Symfony Kernel & Custom Bundles:
still worth it in 2026?

Custom Bundles were long the standard for reusable Symfony modules. With PHP config classes, Symfony Flex and modern DI patterns, there are lighter-weight paths today, but for public packages, complex container manipulation and Compiler Passes, bundles are still the right tool.

17 min read Kernel · Bundles · Compiler Passes · Extensions · PHP Config Symfony 7.x · PHP 8.4

1. Understanding the Symfony Kernel architecture

The Symfony Kernel is the central bootstrapping mechanism of every Symfony application. It bootstraps the dependency injection container, registers bundles, compiles the container and makes it available for the entire application lifetime. The registerBundles() method on the kernel returns a list of all active Symfony Bundles. The order in which they are registered determines which configuration is allowed to override which other one. The container compilation step is the moment when all bundle extensions, service definitions and Compiler Passes run.

On startup, the kernel runs through several clearly defined phases. First, all Symfony Bundles are instantiated via registerBundles(). Then the kernel calls build(ContainerBuilder) on each bundle, where Compiler Passes are registered. It then loads configuration files and calls the bundle extensions, which write services into the container builder. Finally, all Compiler Passes run in the defined order, and the container is compiled and cached. This flow is identical in every Symfony project. Custom Bundles hook into exactly these phases, which makes them both powerful and complex.

2. Anatomy of a Custom Bundle

A Custom Bundle in Symfony is a PHP class that extends AbstractBundle (Symfony 6.1+) or Bundle. The newer AbstractBundle base class significantly simplifies the earlier split into a bundle class, extension class and configuration class: everything now lives in a single class. The configure(DefinitionConfigurator) method defines the configuration schema, loadExtension() loads service definitions and injects configuration values, and build(ContainerBuilder) registers Compiler Passes. This consolidation makes Custom Bundles considerably easier to write in 2026 than they were back in Symfony 4 or 5.

The directory structure of a well-organized Custom Bundle follows a clear convention: the bundle class sits in the package root, and services are registered either in Resources/config/ or via PHP closures directly inside the bundle class. For public packages on Packagist, the bundle belongs in its own Composer namespace, autoloaded through composer.json. For bundles used only within a single application, the code lives under src/Bundle/ or directly under src/ as an ordinary service, which raises the question of whether a bundle is even needed here.


<?php

declare(strict_types=1);

namespace Mironsoft\NotificationBundle;

use Mironsoft\NotificationBundle\DependencyInjection\Compiler\NotificationChannelPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;

/**
 * Symfony Bundle for multi-channel notifications.
 * Uses the modern AbstractBundle API (Symfony 6.1+).
 */
final class MironsoftNotificationBundle extends AbstractBundle
{
    /**
     * Define the configuration schema accepted by this bundle.
     */
    public function configure(\Symfony\Component\Config\Definition\Builder\DefinitionConfigurator $definition): void
    {
        $definition->rootNode()
            ->children()
                ->scalarNode('default_channel')->defaultValue('email')->end()
                ->booleanNode('queue_notifications')->defaultTrue()->end()
                ->arrayNode('channels')
                    ->scalarPrototype()->end()
                ->end()
            ->end();
    }

    /**
     * Load services and inject resolved configuration values.
     *
     * @param array<string, mixed> $config
     */
    public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
    {
        $container->import('../config/services.php');

        // Inject resolved config values directly into service definitions
        $builder->getDefinition('mironsoft_notification.dispatcher')
            ->setArgument('$defaultChannel', $config['default_channel'])
            ->setArgument('$queueEnabled', $config['queue_notifications']);
    }

    /**
     * Register compiler passes that run at container compile time.
     */
    public function build(ContainerBuilder $container): void
    {
        parent::build($container);
        $container->addCompilerPass(new NotificationChannelPass());
    }
}

3. Bundle extension: registering services

With the modern AbstractBundle approach, services are registered directly in loadExtension(), either through a separate PHP services configuration file or inline. The injected configuration is already validated and filled in with default values before loadExtension() is called. That enables conditional service registration: if an optional feature is disabled in the configuration, the corresponding service is never even added to the container. That is cleaner than service definitions guarded by toggleable tags or conditional decorators.

A common pattern in Custom Bundles is registering extension points through tagged services. The bundle defines an interface and a tag, application code implements the interface and tags services with it, and a Compiler Pass collects all tagged services and injects them into the central service. The classic example: a notification bundle defines NotificationChannelInterface and the tag mironsoft.notification.channel. Application services that implement this interface and carry this tag are automatically registered as channels on the next container compile, with no manual registration needed in the bundle code.

4. Compiler Passes: container manipulation at compile time

Compiler Passes are the most powerful feature of Symfony Bundles, and the main reason bundles still make sense in complex scenarios. A Compiler Pass implements CompilerPassInterface and gets access to the fully built ContainerBuilder before it is compiled and cached. That allows structural changes to the container that are not possible through normal configuration: modifying service definitions after the fact, collecting services with certain tags and injecting them into other services, building decorator chains, and cloning or removing definitions.

The tagged-services pattern via Compiler Passes is one of the most elegant patterns in Symfony Bundle architecture. The pass iterates over all services with a given tag, collects their service IDs and injects them as an argument into the central dispatcher. The result: developers can add new channels, handlers or transformers simply by implementing an interface in a class and tagging the service correctly, without touching the bundle code or the dispatcher class. Symfony's own bundles use this pattern extensively: event listeners, console commands, security voters and form types are all registered through tagged services and Compiler Passes in the kernel.


<?php

declare(strict_types=1);

namespace Mironsoft\NotificationBundle\DependencyInjection\Compiler;

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

/**
 * Collects all tagged notification channels and injects them into the dispatcher.
 * Runs at container compile time, not at runtime.
 */
final class NotificationChannelPass implements CompilerPassInterface
{
    public const TAG = 'mironsoft.notification.channel';

    /**
     * Gather all channel services by tag and inject them into the dispatcher.
     */
    public function process(ContainerBuilder $container): void
    {
        if (!$container->has('mironsoft_notification.dispatcher')) {
            return;
        }

        $dispatcher = $container->findDefinition('mironsoft_notification.dispatcher');

        // Find all services tagged with our channel tag, sorted by priority
        $taggedServices = $container->findTaggedServiceIds(self::TAG, throwOnAbstract: true);

        $channels = [];
        foreach ($taggedServices as $serviceId => $tags) {
            foreach ($tags as $attributes) {
                $priority = (int) ($attributes['priority'] ?? 0);
                $channels[$priority][] = new Reference($serviceId);
            }
        }

        // Sort by priority descending, flatten and inject
        krsort($channels);
        $flatChannels = array_merge(...$channels);

        $dispatcher->setArgument('$channels', $flatChannels);
    }
}

5. PHP config classes as a bundle alternative

Since Symfony 5.3 there has been a lighter-weight alternative to the full Custom Bundle: PHP config classes. Instead of a bundle class with an extension and a Compiler Pass, you define a PHP class that uses ContainerConfigurator and is imported into config/services.php. For code organization internal to an application, meaning features that are not distributed as a package, this is sufficient in most cases and considerably easier to understand and maintain.

PHP config classes support tags, service definitions, parameters and all the usual DI features. What they cannot do: define the bundle's configuration schema (with validation and default values), register Compiler Passes, or reach into other bundles' containers. For projects where a feature exists only within the application and needs no public configuration interface, the PHP config approach is simpler, faster to build and easier to debug. The line between bundle and PHP config is therefore clearer today: bundles for public, configurable packages, PHP config for application-internal cross-cutting code.

6. Symfony Flex and recipes instead of bundle boilerplate

Symfony Flex has changed how Symfony Bundles get installed. A recipe is a JSON manifest that, on composer require, automatically creates configuration files, adds environment variables to .env and registers the bundle in config/bundles.php. That makes the onboarding experience of a public bundle radically simpler: the user runs a single Composer command and ends up with a working base configuration, without manually creating files or registering bundle classes.

When deciding whether a project needs a full Custom Bundle or a simpler solution, the Flex perspective is more helpful than the technical one: if the result should be installable with composer require and configurable through YAML or PHP config, you need a bundle. If the code only needs to be structured internally within the project, services, PHP config classes and Symfony conventions are enough. That is the pragmatic answer to whether Custom Bundles still make sense in 2026: yes, but only for packages that must be distributed publicly, be configurable, and be independent of any one specific application.

7. When a Custom Bundle is still the right choice

A Custom Bundle is the right choice in four concrete scenarios. First: the module is published as a Composer package and installed by other projects. Only bundles can be configured automatically through Flex recipes. Second: the module provides a configuration interface that other developers are meant to configure in their config/packages/ file, complete with validation, default values and IDE support. Third: the code needs to register Compiler Passes in order to reach into other bundles' definitions or process tagged services. Fourth: the module needs to be tested across different Symfony versions or in combination with other bundles. Bundles have a clear integration test approach with KernelTestCase.

If none of these four scenarios apply, a Custom Bundle is overhead. A project feature that is only used internally does not need a bundle class. Symfony autowiring, autoconfiguration and PHP config classes solve 95 percent of code organization needs without the bundle concept. The most common mistake: developers build bundles for features that exist only in a single project and are never distributed as a package. That leads to unnecessary complexity with no added value: a bundle directory, extension class, configuration class, all for code that could just as well live directly under src/.


<?php

declare(strict_types=1);

// ALTERNATIVE TO A BUNDLE: PHP Config class for internal application features
// src/Config/NotificationConfig.php, no Bundle class needed

namespace App\Config;

use App\Notification\EmailChannel;
use App\Notification\SmsChannel;
use App\Notification\NotificationDispatcher;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

/**
 * Registers all notification services without a Symfony Bundle.
 * Suitable for application-internal features not distributed as a package.
 */
function configureNotifications(ContainerConfigurator $container): void
{
    $services = $container->services();

    // Register channels with autoconfiguration tags
    $services
        ->set(EmailChannel::class)
        ->tag('app.notification.channel', ['priority' => 100]);

    $services
        ->set(SmsChannel::class)
        ->tag('app.notification.channel', ['priority' => 50]);

    // Dispatcher receives channels via tagged_iterator, no Compiler Pass needed
    $services
        ->set(NotificationDispatcher::class)
        ->arg('$channels', tagged_iterator('app.notification.channel'));
}

// In config/services.php:
// (static function(ContainerConfigurator $container): void {
//     \App\Config\configureNotifications($container);
// })($container);

8. Validating bundle configuration in tests

One frequently neglected aspect of Custom Bundles is the testability of the configuration itself. Symfony offers, through the Extension\ExtensionInterface test infrastructure and ContainerBuilder, a way to test bundle extensions without booting the full kernel. You instantiate ContainerBuilder directly, call the extension method and check whether the expected service definitions are present. That is faster than KernelTestCase and isolates configuration errors from application errors.

The same principle applies to Compiler Pass tests: build a ContainerBuilder, register services with the expected tags, run the Compiler Pass and check whether the dispatcher definition contains the correct argument. These tests run in milliseconds and need no real Symfony kernel. With this test coverage, a Custom Bundle is protected against configuration regressions before it is published as a Composer package. Missing tags, wrong service IDs and incompatible configuration values surface in CI, not in production.

9. Bundle vs. PHP config vs. service provider compared

The three most common approaches to code organization in Symfony projects differ in complexity, flexibility and the right use case. A direct comparison helps with the decision.

Criterion Custom Bundle PHP Config Class Autowiring + Tags
Publicly distributable Yes, with a Flex recipe No No
Configuration schema Yes, with validation Only via parameters No
Compiler Passes Yes No Only via tagged_iterator
Complexity High Low Very low
Suitable for Packages, plugins Internal modules Individual services

The recommendation for new projects in 2026: start with autowiring and PHP config. If the module needs to be distributed as a package or requires Compiler Passes, switch to a full Custom Bundle with AbstractBundle. Existing bundles that are only used internally within an application can be migrated to PHP config classes step by step, gaining reduced complexity and easier debugging along the way.

Mironsoft

Symfony architecture, bundle development and DI container expertise

Building the Symfony architecture for your project?

We design the right module architecture for your Symfony project, from Compiler Passes and Custom Bundles to PHP config classes and Flex recipes for public packages.

Bundle development

Custom Bundles with AbstractBundle, extension, Compiler Passes and Flex recipe

DI architecture

Tagged services, PHP config classes and an optimized container setup

Migration

Modernizing legacy bundles and migrating to AbstractBundle or PHP config

10. Summary

Symfony Custom Bundles are not an outdated concept in 2026, but their scope is more clearly defined than in earlier Symfony versions. With AbstractBundle, bundles are considerably easier to write, and the split into an extension class and a configuration class disappears. Compiler Passes remain the most powerful tool for structural container manipulation and tagged-service aggregation, and for that the bundle remains the right tool. For application-internal code organization, PHP config classes, autowiring and tags are, in nearly all cases, the better, simpler alternative.

The pragmatic decision rule for 2026: if the module will land on Packagist or be installed by other developers via composer require, you need a bundle, complete with a Flex recipe and a configuration schema. If the code stays internal, services and PHP config are enough. That eliminates a large share of bundle boilerplate from projects that never needed it, and it makes the codebase easier to understand, test and maintain.

Symfony Custom Bundles in 2026, the essentials at a glance

When a bundle makes sense

Public Composer packages, configurable modules with validation, Compiler Passes and a Flex recipe. Only then is the bundle overhead worth it.

AbstractBundle (Symfony 6.1+)

One class instead of three: configure(), loadExtension() and build() in the bundle class, no separate extension or configuration object needed anymore.

Compiler Passes

Collecting tagged services and injecting them into dispatchers, the most powerful feature of bundles that PHP config classes cannot replicate.

Internal alternative

PHP config classes and tagged_iterator() replace bundles for application-internal code, simpler, faster to build and easier to debug.

11. FAQ: Symfony Custom Bundles and kernel architecture

1What is a Symfony Bundle?
A PHP class that brings services, configuration and Compiler Passes into the Symfony container. It hooks into the kernel bootstrapping process via registerBundles().
2What is AbstractBundle?
The recommended base class since Symfony 6.1, consolidating bundle, extension and configuration into one class. configure(), loadExtension() and build() replace the earlier three-way split.
3What is a Compiler Pass?
Implements CompilerPassInterface and modifies the ContainerBuilder at compile time, collecting tagged services, changing definitions, building decorator chains.
4When is no bundle needed?
When the code is used only internally within one application, autowiring, PHP config classes and tagged_iterator() are entirely sufficient.
5Extension vs. PHP config?
Extension: validated configuration schema, Compiler Passes, publicly configurable. PHP config: simple service registration without a schema and without Compiler Pass support.
6What is Symfony Flex?
A Composer plugin that runs recipes: creating configuration files automatically and registering bundles on composer require, one command, ready to use immediately.
7Register a bundle in the kernel?
config/bundles.php: ['Vendor\\Bundle\\MyBundle' => ['all' => true]]. Symfony Flex does this automatically on composer require.
8Tagged services without a bundle?
Yes. tagged_iterator() and tagged_locator() in PHP config or YAML work without a bundle, for simple aggregation patterns no Compiler Pass is needed.
9Testing a bundle extension?
Instantiate ContainerBuilder directly, call loadExtension() with a test configuration and check the service definitions, faster than a full kernel test.
10Are bundles still supported in Symfony 7?
Yes, fully. AbstractBundle is being actively developed further. No deprecation plans for the bundle system, it remains the central extensibility concept for public packages.