Advanced Symfony bundle patterns: compiler passes, event system, security voters, conditional service registration, configuration validation, deep merge pitfalls, cache warmers, and semantic versioning contracts. Referenced from the main symfony-bundle-core skill for deeper topics.
php artisan boost:add-skill symkit/metadata-bundle
Save this content to: .claude/skills/symfony-bundle-core-advanced-patterns/SKILL.md
# Advanced Bundle Patterns Reference
## Compiler Passes
Use compiler passes for DI manipulation that can't be expressed declaratively:
```php
<?php
// src/DependencyInjection/CompilerPass/PluginRegistrationPass.php
namespace Acme\BlogBundle\DependencyInjection\CompilerPass;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
class PluginRegistrationPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has('acme_blog.plugin_registry')) {
return;
}
$definition = $container->findDefinition('acme_blog.plugin_registry');
$taggedServices = $container->findTaggedServiceIds('acme_blog.plugin');
foreach ($taggedServices as $id => $tags) {
$definition->addMethodCall('addPlugin', [new Reference($id)]);
}
}
}
```
Register in the bundle class:
```php
public function build(ContainerBuilder $container): void
{
parent::build($container);
$container->addCompilerPass(new PluginRegistrationPass());
}
```
## Event System
Expose bundle events for extensibility:
```php
<?php
// src/Event/ArticlePublishedEvent.php
namespace Acme\BlogBundle\Event;
use Acme\BlogBundle\Entity\Article;
use Symfony\Contracts\EventDispatcher\Event;
class ArticlePublishedEvent extends Event
{
public const NAME = 'acme_blog.article.published';
public function __construct(
private readonly Article $article,
) {}
public function getArticle(): Article
{
return $this->article;
}
}
```
## Security Voter
If bundle needs authorization logic:
```php
<?php
// src/Security/ArticleVoter.php
namespace Acme\BlogBundle\Security;
use Acme\BlogBundle\Entity\Article;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
public const EDIT = 'ACME_BLOG_ARTICLE_EDIT';
public const DELETE = 'ACME_BLOG_ARTICLE_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE])
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
// Bundle provides base logic; host app can decorate or replace
return false;
}
}
```
Prefix voter attributes with bundle name to avoid collisions.
## Conditional Service Registration
Handle optional dependencies gracefully:
```php
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
$container->import('../config/services.xml');
// Only register Twig extension if Twig is available
if (interface_exists(\Twig\Extension\ExtensionInterface::class)) {
$container->import('../config/services_twig.xml');
}
// Only register Doctrine listeners if Doctrine is available
if (interface_exists(\Doctrine\ORM\EntityManagerInterface::class)) {
$container->import('../config/services_doctrine.xml');
}
}
```
In `composer.json`, these go in `suggest`, not `require`:
```json
{
"suggest": {
"twig/twig": "Required for template rendering (^3.0)",
"doctrine/orm": "Required for database persistence (^3.0)"
}
}
```
## Configuration Validation with Callbacks
For complex validation beyond type checking:
```php
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->arrayNode('connection')
->children()
->scalarNode('host')->isRequired()->end()
->integerNode('port')->defaultValue(443)->end()
->enumNode('protocol')
->values(['https', 'http', 'ws', 'wss'])
->defaultValue('https')
->end()
->end()
->validate()
->ifTrue(function ($v) {
return $v['protocol'] === 'http' && $v['port'] === 443;
})
->thenInvalid('Port 443 is typically used with HTTPS, not HTTP.')
->end()
->end()
->end()
;
}
```
## Deep Merge Pitfalls
When defining array nodes, explicitly control merge behavior:
```php
->arrayNode('widgets')
->useAttributeAsKey('name')
->normalizeKeys(false) // Don't convert dashes to underscores
->arrayPrototype()
->children()
->scalarNode('type')->isRequired()->end()
->arrayNode('options')
->performNoDeepMerging() // CRITICAL: prevent unpredictable deep merge
->variablePrototype()->end()
->end()
->end()
->end()
->end()
```
Without `performNoDeepMerging()`, if a user tries to override a default widget's options, the arrays merge unpredictably instead of replacing cleanly.
## Cache Warmers
If your bundle needs to pre-compute data at cache warmup:
```php
<?php
namespace Acme\BlogBundle\CacheWarmer;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
class PluginCacheWarmer implements CacheWarmerInterface
{
public function isOptional(): bool
{
return true; // Don't block deployment if this fails
}
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
// Pre-compute and cache plugin metadata
return []; // Return list of files generated
}
}
```
Register with tag `kernel.cache_warmer` in services.xml.
## Semantic Versioning Contract
Your bundle's public API (what constitutes a BC break) includes:
- Service IDs marked as `public`
- Configuration tree structure (removing/renaming keys)
- Event class names and properties
- Interface method signatures
- Entity field names (if using inheritance mapping)
- Twig function/filter names
- Stimulus controller names and their values/targets API
Changes to any of these require a major version bump.
Use compiler passes for DI manipulation that can't be expressed declaratively:
<?php
// src/DependencyInjection/CompilerPass/PluginRegistrationPass.php
namespace Acme\BlogBundle\DependencyInjection\CompilerPass;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
class PluginRegistrationPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has('acme_blog.plugin_registry')) {
return;
}
$definition = $container->findDefinition('acme_blog.plugin_registry');
$taggedServices = $container->findTaggedServiceIds('acme_blog.plugin');
foreach ($taggedServices as $id => $tags) {
$definition->addMethodCall('addPlugin', [new Reference($id)]);
}
}
}
Register in the bundle class:
public function build(ContainerBuilder $container): void
{
parent::build($container);
$container->addCompilerPass(new PluginRegistrationPass());
}
Expose bundle events for extensibility:
<?php
// src/Event/ArticlePublishedEvent.php
namespace Acme\BlogBundle\Event;
use Acme\BlogBundle\Entity\Article;
use Symfony\Contracts\EventDispatcher\Event;
class ArticlePublishedEvent extends Event
{
public const NAME = 'acme_blog.article.published';
public function __construct(
private readonly Article $article,
) {}
public function getArticle(): Article
{
return $this->article;
}
}
If bundle needs authorization logic:
<?php
// src/Security/ArticleVoter.php
namespace Acme\BlogBundle\Security;
use Acme\BlogBundle\Entity\Article;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
public const EDIT = 'ACME_BLOG_ARTICLE_EDIT';
public const DELETE = 'ACME_BLOG_ARTICLE_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE])
&& $subject instanceof Article;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
// Bundle provides base logic; host app can decorate or replace
return false;
}
}
Prefix voter attributes with bundle name to avoid collisions.
Handle optional dependencies gracefully:
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{
$container->import('../config/services.xml');
// Only register Twig extension if Twig is available
if (interface_exists(\Twig\Extension\ExtensionInterface::class)) {
$container->import('../config/services_twig.xml');
}
// Only register Doctrine listeners if Doctrine is available
if (interface_exists(\Doctrine\ORM\EntityManagerInterface::class)) {
$container->import('../config/services_doctrine.xml');
}
}
In composer.json, these go in suggest, not require:
{
"suggest": {
"twig/twig": "Required for template rendering (^3.0)",
"doctrine/orm": "Required for database persistence (^3.0)"
}
}
For complex validation beyond type checking:
public function configure(DefinitionConfigurator $definition): void
{
$definition->rootNode()
->children()
->arrayNode('connection')
->children()
->scalarNode('host')->isRequired()->end()
->integerNode('port')->defaultValue(443)->end()
->enumNode('protocol')
->values(['https', 'http', 'ws', 'wss'])
->defaultValue('https')
->end()
->end()
->validate()
->ifTrue(function ($v) {
return $v['protocol'] === 'http' && $v['port'] === 443;
})
->thenInvalid('Port 443 is typically used with HTTPS, not HTTP.')
->end()
->end()
->end()
;
}
When defining array nodes, explicitly control merge behavior:
->arrayNode('widgets')
->useAttributeAsKey('name')
->normalizeKeys(false) // Don't convert dashes to underscores
->arrayPrototype()
->children()
->scalarNode('type')->isRequired()->end()
->arrayNode('options')
->performNoDeepMerging() // CRITICAL: prevent unpredictable deep merge
->variablePrototype()->end()
->end()
->end()
->end()
->end()
Without performNoDeepMerging(), if a user tries to override a default widget's options, the arrays merge unpredictably instead of replacing cleanly.
If your bundle needs to pre-compute data at cache warmup:
<?php
namespace Acme\BlogBundle\CacheWarmer;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
class PluginCacheWarmer implements CacheWarmerInterface
{
public function isOptional(): bool
{
return true; // Don't block deployment if this fails
}
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
// Pre-compute and cache plugin metadata
return []; // Return list of files generated
}
}
Register with tag kernel.cache_warmer in services.xml.
Your bundle's public API (what constitutes a BC break) includes:
publicChanges to any of these require a major version bump.
How can I help you explore Laravel packages today?