Part 11 of 13 in the PHP Package Development series
In the last three posts, we built a Laravel package around our jakovic/text-toolkit library. Now it's time for the other side of the PHP framework world - Symfony. If you've never worked with Symfony before, don't worry. The concepts map surprisingly well from what you already know, and some would argue the structure is even more explicit and predictable.
In this post, we'll build jakovic/text-toolkit-bundle - a Symfony bundle that wraps our existing text manipulation library and makes it a first-class citizen in any Symfony application. By the end, you'll understand bundle structure, dependency injection extensions, configuration trees, service definitions, and even how Symfony Flex recipes work.
Symfony Bundles vs Laravel Packages
Before we write any code, let's clear up the terminology. In Laravel, you create a "package" with a Service Provider that hooks into the framework. In Symfony, the equivalent is a "bundle" - a structured plugin that integrates with Symfony's dependency injection container.
Here's how the concepts map between the two frameworks:
Service Provider vs Bundle Class - Laravel's Service Provider registers bindings, publishes config, and boots the package. Symfony's Bundle class serves a similar entry point role, but delegates most of the heavy lifting to a DependencyInjection Extension class.
Config files - In Laravel, you publish a config file to config/ and merge defaults. In Symfony, you define a Configuration class with a TreeBuilder that validates and normalizes config values. It's more structured, but also more powerful.
Service Registration - Laravel uses $this->app->bind() and $this->app->singleton(). Symfony uses service definition files (YAML, XML, or PHP) loaded by the Extension class.
Auto-discovery - Laravel has package auto-discovery via extra.laravel in composer.json. Symfony has Flex recipes - a more sophisticated system we'll cover later in this post.
The biggest philosophical difference? Symfony leans into explicit configuration. Nothing is magic. Every service is declared, every config key is validated, and every dependency is wired through the container. Its more verbose upfront, but it means fewer surprises in production.
Bundle Directory Structure
Symfony bundles follow a well-defined directory structure. Here's what our jakovic/text-toolkit-bundle will look like:
text-toolkit-bundle/
├── config/
│ └── services.yaml
├── src/
│ ├── JakovicTextToolkitBundle.php
│ └── DependencyInjection/
│ ├── JakovicTextToolkitExtension.php
│ └── Configuration.php
├── composer.json
├── LICENSE
└── README.md
Let's break down each piece:
src/JakovicTextToolkitBundle.php- The bundle class. This is the entry point that Symfony discovers.src/DependencyInjection/JakovicTextToolkitExtension.php- The DI extension that loads configuration and registers services.src/DependencyInjection/Configuration.php- Defines and validates the bundle's configuration schema.config/services.yaml- Service definitions (what gets registered in the container).
Notice the naming conventions. They're not optional - Symfony uses them to auto-discover your extension class. The bundle is called JakovicTextToolkitBundle, so Symfony looks for JakovicTextToolkitExtension in the DependencyInjection namespace. Get the name wrong and nothing loads.
Setting Up composer.json
Let's start at the foundation. Create your project directory and initialize Composer:
mkdir text-toolkit-bundle
cd text-toolkit-bundle
composer init \
--name="jakovic/text-toolkit-bundle" \
--description="Symfony bundle for the Text Toolkit library" \
--type="symfony-bundle" \
--license="MIT" \
--no-interaction
Notice the --type="symfony-bundle". This tells Composer (and Symfony Flex) that this package is a bundle, not a regular library. Now open composer.json and set it up properly:
{
"name": "jakovic/text-toolkit-bundle",
"description": "Symfony bundle for the Text Toolkit library",
"type": "symfony-bundle",
"license": "MIT",
"require": {
"php": "^8.1",
"jakovic/text-toolkit": "^1.0",
"symfony/config": "^6.4 || ^7.0",
"symfony/dependency-injection": "^6.4 || ^7.0",
"symfony/http-kernel": "^6.4 || ^7.0"
},
"require-dev": {
"phpunit/phpunit": "^10.0",
"symfony/framework-bundle": "^6.4 || ^7.0"
},
"autoload": {
"psr-4": {
"Jakovic\\TextToolkitBundle\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Jakovic\\TextToolkitBundle\\Tests\\": "tests/"
}
}
}
A few things to note here. We require jakovic/text-toolkit as a dependency - our bundle wraps the standalone library, it doesn't duplicate it. We also require three Symfony components: config for the Configuration class, dependency-injection for the Extension and container building, and http-kernel for the Bundle base class.
The version constraints ^6.4 || ^7.0 support both Symfony 6.4 LTS and Symfony 7.x. This is the standard approach - support the current LTS and the latest major version.
Creating the Bundle Class
The bundle class is the simplest file in the entire package. It's the entry point that tells Symfony "hey, I exist." In most cases, it's literally an empty class:
<?php
namespace Jakovic\TextToolkitBundle;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
class JakovicTextToolkitBundle extends AbstractBundle
{
}
That's it. Really. Starting with Symfony 6.1, bundles extend AbstractBundle instead of the older Bundle base class. The AbstractBundle provides sensible defaults and less boilerplate.
The naming convention is critical. The class name must end with Bundle. Symfony strips the Bundle suffix and uses the rest to find your Extension class. So JakovicTextToolkitBundle maps to JakovicTextToolkitExtension.
If you need to customize the extension or do something during boot, you can override methods:
<?php
namespace Jakovic\TextToolkitBundle;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class JakovicTextToolkitBundle extends AbstractBundle
{
public function build(ContainerBuilder $container): void
{
parent::build($container);
// Register compiler passes here if needed
}
}
But for now, keep it empty. You'll rarely need to add anything here.
The DependencyInjection Extension Class
This is where the real work happens. The Extension class is the equivalent of Laravel's register() and boot() methods combined. It loads your configuration, processes it, and registers your services in Symfony's container.
<?php
namespace Jakovic\TextToolkitBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class JakovicTextToolkitExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
// Load service definitions
$loader = new YamlFileLoader(
$container,
new FileLocator(__DIR__ . '/../../config')
);
$loader->load('services.yaml');
// Process configuration
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
// Pass config values to service definitions
$definition = $container->getDefinition('jakovic_text_toolkit.text_processor');
$definition->setArgument('$defaultLocale', $config['default_locale']);
$definition->setArgument('$slugSeparator', $config['slug_separator']);
$definition->setArgument('$truncateSuffix', $config['truncate_suffix']);
}
}
Let's walk through what's happening here step by step.
Loading service definitions - The YamlFileLoader reads your services.yaml file and registers all the services defined in it. The FileLocator tells it where to find the file. We point it to the config/ directory relative to the Extension class.
Processing configuration - The $configs parameter contains all the configuration arrays from the application. If the user has config in config/packages/jakovic_text_toolkit.yaml, it shows up here. The processConfiguration() method validates it against your Configuration class and merges defaults.
Wiring config to services - After processing, we grab the service definition and inject the config values as constructor arguments. This is how configuration flows from YAML files into your actual service classes.
The class name must follow the convention: your bundle name minus the Bundle suffix, plus Extension. So JakovicTextToolkitBundle expects JakovicTextToolkitExtension. It must live in the DependencyInjection sub-namespace. Break either convention and Symfony won't find it.
The Configuration Class (TreeBuilder)
This is one of the things Symfony does better than any other PHP framework - structured, validated configuration. Instead of just reading an array and hoping the keys exist, you define a schema using teh TreeBuilder. Symfony validates the user's config against it, provides defaults, and throws clear errors if something's wrong.
<?php
namespace Jakovic\TextToolkitBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('jakovic_text_toolkit');
$treeBuilder->getRootNode()
->children()
->scalarNode('default_locale')
->defaultValue('en')
->info('The default locale for text processing')
->end()
->scalarNode('slug_separator')
->defaultValue('-')
->info('Character used to separate words in slugs')
->end()
->scalarNode('truncate_suffix')
->defaultValue('...')
->info('String appended to truncated text')
->end()
->integerNode('max_excerpt_length')
->defaultValue(200)
->min(10)
->max(10000)
->info('Maximum length for auto-generated excerpts')
->end()
->booleanNode('strip_html')
->defaultTrue()
->info('Whether to strip HTML tags during processing')
->end()
->end()
;
return $treeBuilder;
}
}
The root node name jakovic_text_toolkit determines the configuration key. Users will configure your bundle under this key in their Symfony app:
# config/packages/jakovic_text_toolkit.yaml
jakovic_text_toolkit:
default_locale: 'fr'
slug_separator: '_'
truncate_suffix: '[...]'
max_excerpt_length: 150
strip_html: true
The TreeBuilder supports a rich set of node types:
scalarNode()- strings, numbers, or booleansintegerNode()- integers with optionalmin()/max()validationfloatNode()- floating point numbersbooleanNode()- true/false withdefaultTrue()/defaultFalse()enumNode()- restricted to specific valuesarrayNode()- nested arrays and complex structures
Here's a more advanced example showing nested configuration:
$treeBuilder->getRootNode()
->children()
->arrayNode('markdown')
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')
->defaultTrue()
->end()
->enumNode('flavor')
->values(['github', 'commonmark', 'basic'])
->defaultValue('commonmark')
->end()
->end()
->end()
->end()
;
If a user passes an invalid value - say flavor: 'wordpress' - Symfony throws a clear error: "The value 'wordpress' is not allowed for path jakovic_text_toolkit.markdown.flavor. Permissible values: 'github', 'commonmark', 'basic'". You get validation for free, no manual checking needed.
Service Definitions with services.yaml
Now we need to tell Symfony which classes to register in the dependency injection container. Create config/services.yaml:
services:
jakovic_text_toolkit.slugifier:
class: Jakovic\TextToolkit\Slugifier
public: false
jakovic_text_toolkit.truncator:
class: Jakovic\TextToolkit\Truncator
public: false
jakovic_text_toolkit.excerpt_generator:
class: Jakovic\TextToolkit\ExcerptGenerator
public: false
jakovic_text_toolkit.text_processor:
class: Jakovic\TextToolkitBundle\TextProcessor
arguments:
$slugifier: '@jakovic_text_toolkit.slugifier'
$truncator: '@jakovic_text_toolkit.truncator'
$excerptGenerator: '@jakovic_text_toolkit.excerpt_generator'
$defaultLocale: 'en'
$slugSeparator: '-'
$truncateSuffix: '...'
public: true
# Create aliases so users can type-hint the interface
Jakovic\TextToolkitBundle\TextProcessor:
alias: jakovic_text_toolkit.text_processor
public: true
Let's break down what's happening here.
Each service gets a unique ID. The convention is to use your bundle prefix followed by the service name: jakovic_text_toolkit.slugifier. The class key tells Symfony which PHP class to instantiate.
Services marked public: false can't be fetched directly from the container - they're only available for injection. This is the default in Symfony and it's a good practice. Only make services public if users need to grab them from the container directly.
The arguments key wires dependencies. The @ prefix means "inject this service." Named arguments like $slugifier map directly to constructor parameter names.
The alias at the bottom is important - it lets users type-hint TextProcessor in their controllers and have Symfony's autowiring inject the right service automatically.
Now let's create the TextProcessor class that brings everything together:
<?php
namespace Jakovic\TextToolkitBundle;
use Jakovic\TextToolkit\Slugifier;
use Jakovic\TextToolkit\Truncator;
use Jakovic\TextToolkit\ExcerptGenerator;
class TextProcessor
{
public function __construct(
private Slugifier $slugifier,
private Truncator $truncator,
private ExcerptGenerator $excerptGenerator,
private string $defaultLocale = 'en',
private string $slugSeparator = '-',
private string $truncateSuffix = '...',
) {}
public function slugify(string $text, ?string $separator = null): string
{
return $this->slugifier->slugify(
$text,
$separator ?? $this->slugSeparator
);
}
public function truncate(string $text, int $length, ?string $suffix = null): string
{
return $this->truncator->truncate(
$text,
$length,
$suffix ?? $this->truncateSuffix
);
}
public function excerpt(string $text, ?int $length = null): string
{
return $this->excerptGenerator->generate($text, $length);
}
public function getDefaultLocale(): string
{
return $this->defaultLocale;
}
}
This class wraps the standalone library classes and applies configuration defaults. Users can inject TextProcessor into any Symfony controller or service and use it right away.
Registering Services in the Container
We've seen YAML-based service definitions, but sometimes you need more control. You can define services directly in PHP within your Extension class. Let's update the extension to handle the max_excerpt_length and strip_html config options too:
<?php
namespace Jakovic\TextToolkitBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
class JakovicTextToolkitExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new YamlFileLoader(
$container,
new FileLocator(__DIR__ . '/../../config')
);
$loader->load('services.yaml');
$configuration = new Configuration();
$config = $this->processConfiguration($configuration, $configs);
// Set container parameters for access elsewhere
$container->setParameter(
'jakovic_text_toolkit.default_locale',
$config['default_locale']
);
$container->setParameter(
'jakovic_text_toolkit.max_excerpt_length',
$config['max_excerpt_length']
);
// Wire config values into the TextProcessor service
$definition = $container->getDefinition('jakovic_text_toolkit.text_processor');
$definition->setArgument('$defaultLocale', $config['default_locale']);
$definition->setArgument('$slugSeparator', $config['slug_separator']);
$definition->setArgument('$truncateSuffix', $config['truncate_suffix']);
// Configure the excerpt generator
$excerptDef = $container->getDefinition('jakovic_text_toolkit.excerpt_generator');
$excerptDef->addMethodCall('setMaxLength', [$config['max_excerpt_length']]);
$excerptDef->addMethodCall('setStripHtml', [$config['strip_html']]);
}
}
There are two approaches happening here. For the TextProcessor, we set constructor arguments directly. For the ExcerptGenerator, we use addMethodCall() - which calls setter methods after the service is instantiated. Use whichever fits your class design.
We also set container parameters with setParameter(). This makes config values available to other bundles or to the application itself via %jakovic_text_toolkit.default_locale% in YAML or $container->getParameter() in PHP.
Using XML Instead of YAML
Some bundle authors prefer XML for service definitions because it's the "traditional" Symfony way and provides IDE autocompletion via XSD schemas. Here's the same service file in XML:
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
https://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="jakovic_text_toolkit.slugifier"
class="Jakovic\TextToolkit\Slugifier"
public="false" />
<service id="jakovic_text_toolkit.text_processor"
class="Jakovic\TextToolkitBundle\TextProcessor"
public="true">
<argument key="$slugifier"
type="service"
id="jakovic_text_toolkit.slugifier" />
<argument key="$defaultLocale">en</argument>
</service>
</services>
</container>
If you go this route, change the loader in your Extension class:
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
$loader = new XmlFileLoader(
$container,
new FileLocator(__DIR__ . '/../../config')
);
$loader->load('services.xml');
YAML or XML - pick whichever you're comfortable with. YAML is more concise and easier to read for most developers. XML has better tooling support. Both are equally valid.
Symfony Flex Recipes
Symfony Flex is the tool that makes installing bundles smooth. When you run composer require some/bundle, Flex can automatically enable the bundle, create default config files, and set up anything else your bundle needs. It does this through "recipes."
A recipe is a small set of instructions stored in a Git repository. There are two recipe repositories:
- Official recipes (
symfony/recipes) - Curated by the Symfony core team. These are for popular, well-maintained packages. - Community recipes (
symfony/recipes-contrib) - Open to anyone. Submit a PR and get your recipe included.
A recipe for our bundle would look like this. First, you create a directory structure in the recipes repository:
jakovic/text-toolkit-bundle/
└── 1.0/
├── manifest.json
└── config/
└── packages/
└── jakovic_text_toolkit.yaml
The manifest.json tells Flex what to do:
{
"bundles": {
"Jakovic\\TextToolkitBundle\\JakovicTextToolkitBundle": ["all"]
}
}
This tells Flex to register the bundle in config/bundles.php for all environments. The "all" value means it's enabled in dev, test, and prod. You could also use ["dev", "test"] for development-only bundles.
The config file config/packages/jakovic_text_toolkit.yaml provides a sensible default configuration:
jakovic_text_toolkit:
default_locale: '%kernel.default_locale%'
slug_separator: '-'
truncate_suffix: '...'
When a user runs composer require jakovic/text-toolkit-bundle, Flex will automatically add the bundle to config/bundles.php and copy the default config file. No manual setup required.
To submit your recipe, fork symfony/recipes-contrib on GitHub, add your recipe directory, and open a pull request. The Symfony team reviews community recipes, so make sure your config file has good defaults and comments.
Installing and Testing in a Symfony App
Let's test our bundle in a real Symfony application. If you don't have one handy, create a fresh app:
composer create-project symfony/skeleton my-test-app
cd my-test-app
Local Development with Path Repository
During development, you don't want to publish to Packagist just to test. Use Composer's path repository feature instead. Add this to the Symfony app's composer.json:
{
"repositories": [
{
"type": "path",
"url": "../text-toolkit-bundle"
},
{
"type": "path",
"url": "../text-toolkit"
}
]
}
We include both the bundle and the base library since the bundle depends on it. Now install it:
composer require jakovic/text-toolkit-bundle:@dev
Registering the Bundle
If you don't have a Flex recipe (and you won't during development), you need to register the bundle manually. Open config/bundles.php:
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
// ... other bundles
Jakovic\TextToolkitBundle\JakovicTextToolkitBundle::class => ['all' => true],
];
Adding Configuration
Create the configuration file config/packages/jakovic_text_toolkit.yaml:
jakovic_text_toolkit:
default_locale: 'en'
slug_separator: '-'
truncate_suffix: '...'
max_excerpt_length: 200
strip_html: true
Using the Service
Now you can inject TextProcessor into any controller or service:
<?php
namespace App\Controller;
use Jakovic\TextToolkitBundle\TextProcessor;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class DemoController extends AbstractController
{
#[Route('/demo', name: 'demo')]
public function index(TextProcessor $textProcessor): JsonResponse
{
return new JsonResponse([
'slug' => $textProcessor->slugify('Hello World!'),
'truncated' => $textProcessor->truncate(
'This is a long text that needs truncating',
20
),
'excerpt' => $textProcessor->excerpt(
'<p>This is some <strong>HTML</strong> content.</p>'
),
]);
}
}
Symfony's autowiring handles the injection. Because we defined the class alias in services.yaml, type-hinting TextProcessor in the constructor is all that's needed.
Verifying It Works
Use the Symfony console to verify your services are registered:
# List all services from your bundle
php bin/console debug:container | grep jakovic_text_toolkit
# Check configuration
php bin/console debug:config jakovic_text_toolkit
# Dump the resolved config with defaults
php bin/console config:dump jakovic_text_toolkit
The debug:container command shows all registered services. You should see your services listed with their class names. The debug:config command shows the current configuration, and config:dump shows the full configuration reference with all defaults. These are incredibly useful debugging tools that you get for free with Symfony's configuration system.
The Modern Approach - AbstractBundle
Everything we've built so far follows the traditional pattern with separate Extension and Configuration classes. Starting with Symfony 6.1, there's a more streamlined approach using AbstractBundle that lets you define everything in the Bundle class itself:
<?php
namespace Jakovic\TextToolkitBundle;
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;
class JakovicTextToolkitBundle extends AbstractBundle
{
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->scalarNode('default_locale')
->defaultValue('en')
->end()
->scalarNode('slug_separator')
->defaultValue('-')
->end()
->scalarNode('truncate_suffix')
->defaultValue('...')
->end()
->end()
;
}
public function loadExtension(
array $config,
ContainerConfigurator $container,
ContainerBuilder $builder
): void {
$container->import('../config/services.yaml');
$container->services()
->get('jakovic_text_toolkit.text_processor')
->arg('$defaultLocale', $config['default_locale'])
->arg('$slugSeparator', $config['slug_separator'])
->arg('$truncateSuffix', $config['truncate_suffix'])
;
}
}
This approach puts configuration and service loading in the same file. No separate Extension class, no separate Configuration class. For simpler bundles, this is cleaner. For complex bundles with lots of configuration and compiler passes, the traditional approach with separate files keeps things organized.
When you use AbstractBundle with configure() and loadExtension(), Symfony ignores any Extension class in the DependencyInjection directory. So pick one approach or the other - don't mix them.
Complete File Reference
Let's put together the complete file structure with all the code in one place. This is the traditional approach with separate classes, which we'll continue using in Part 12.
The final directory structure:
text-toolkit-bundle/
├── config/
│ └── services.yaml
├── src/
│ ├── JakovicTextToolkitBundle.php
│ ├── TextProcessor.php
│ └── DependencyInjection/
│ ├── JakovicTextToolkitExtension.php
│ └── Configuration.php
├── tests/
│ └── DependencyInjection/
│ └── JakovicTextToolkitExtensionTest.php
├── composer.json
├── LICENSE
└── README.md
Here's a quick integration test to verify your extension loads correctly:
<?php
namespace Jakovic\TextToolkitBundle\Tests\DependencyInjection;
use Jakovic\TextToolkitBundle\DependencyInjection\JakovicTextToolkitExtension;
use Jakovic\TextToolkitBundle\TextProcessor;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class JakovicTextToolkitExtensionTest extends TestCase
{
public function testServicesAreRegistered(): void
{
$container = new ContainerBuilder();
$extension = new JakovicTextToolkitExtension();
$extension->load([], $container);
$this->assertTrue(
$container->hasDefinition('jakovic_text_toolkit.text_processor')
);
$this->assertTrue(
$container->hasDefinition('jakovic_text_toolkit.slugifier')
);
}
public function testConfigurationDefaults(): void
{
$container = new ContainerBuilder();
$extension = new JakovicTextToolkitExtension();
$extension->load([], $container);
$definition = $container->getDefinition('jakovic_text_toolkit.text_processor');
$this->assertSame('en', $definition->getArgument('$defaultLocale'));
$this->assertSame('-', $definition->getArgument('$slugSeparator'));
}
public function testCustomConfiguration(): void
{
$container = new ContainerBuilder();
$extension = new JakovicTextToolkitExtension();
$extension->load([
[
'default_locale' => 'de',
'slug_separator' => '_',
'truncate_suffix' => '[...]',
]
], $container);
$definition = $container->getDefinition('jakovic_text_toolkit.text_processor');
$this->assertSame('de', $definition->getArgument('$defaultLocale'));
$this->assertSame('_', $definition->getArgument('$slugSeparator'));
$this->assertSame('[...]', $definition->getArgument('$truncateSuffix'));
}
}
This test creates a container in isolation, loads the extension, and verifies that services are registered with the right configuration. No Symfony kernel needed - just pure unit testing of your DI setup.
Common Pitfalls
Before we wrap up, here are the mistakes that trip up almost every first-time bundle author:
Wrong naming conventions - If your bundle is JakovicTextToolkitBundle, the extension MUST be JakovicTextToolkitExtension in the DependencyInjection namespace. Not TextToolkitExtension, not JakovicExtension. Symfony won't throw an error - it just silently won't load your extension.
Wrong FileLocator path - The path in new FileLocator(__DIR__ . '/../../config') is relative to the Extension class, not the project root. Get this wrong and you'll see "file not found" errors.
Forgetting to make services public - If users need to fetch a service from the container or use it with autowiring, it needs to be public or have a public alias. Private services can only be injected into other services defined in your bundle.
Not supporting multiple Symfony versions - Always test against both the current LTS and the latest version. Use ^6.4 || ^7.0 style constraints and check the Symfony upgrade guides for deprecations.
What's Next
We've built the foundation of a Symfony bundle - the structure, configuration, and service registration. In Part 12, we'll add the features that make bundles truly useful: Twig extensions (so users can call {{ text|slugify }} in templates), console commands, event subscribers, and proper bundle testing with a test kernel. That's where our bundle goes from "working" to "complete."
The key takeaway from this post: Symfony bundles are more structured than Laravel packages, but that structure pays off. Validated configuration, explicit service wiring, and Flex recipes give you a professional, predictable developer experience. Once you understand the naming conventions and the Extension/Configuration/services.yaml trio, building bundles becomes second nature.