Use this skill whenever the user wants to create, scaffold, architect, or configure a Symfony bundle (reusable PHP package for Symfony 7+/8+). Triggers include: 'create a bundle', 'symfony bundle', 'AbstractBundle', 'bundle structure', 'bundle skeleton', 'reusable symfony component', 'bundle configuration', 'DI extension', 'service container for bundle', 'configure()', 'loadExtension()', 'prependExtension()', 'bundle best practices', 'composer.json for bundle', 'SOLID bundle'. Also trigger when the user is building any reusable Symfony package, even if they don't explicitly say 'bundle'. Do NOT trigger for application-level Symfony development that doesn't involve creating a distributable package.
php artisan boost:add-skill symkit/search-bundle
Save this content to: .claude/skills/symfony-bundle-core/SKILL.md
# Symfony Bundle Core — Architecture & Scaffolding (Symfony 7+/8+)
This skill covers the full lifecycle of creating a modern Symfony bundle: project scaffolding, AbstractBundle configuration, dependency injection, service registration, routing, translations, testing, and distribution.
For frontend assets (AssetMapper, Stimulus, Twig Components, Live Components), see the `symfony-bundle-ux` skill.
For Doctrine entity modeling in bundles, see the `symfony-bundle-doctrine` skill.
For Flex recipes and distribution, see the `symfony-bundle-flex` skill.
For quality tooling (Makefile, PHPStan, GrumPHP, Infection, CI), see the `symfony-bundle-quality` skill.
---
## 1. SOLID Principles for Bundles
A bundle is code reusable and modifiable by the entire community. SOLID is not optional.
- **S (Single Responsibility)**: One class, one responsibility. Services finely decomposed. No "god service" doing 5 things.
- **O (Open/Closed)**: Extensible without modification via events, interfaces, compiler passes, decorators. `final readonly class` on concrete classes forces extension by composition, not inheritance.
- **L (Liskov Substitution)**: Bundle interfaces are substitutable. No `instanceof` checks on concrete implementations internally.
- **I (Interface Segregation)**: One interface = one precise contract. No "god interface" with 10 methods.
- **D (Dependency Inversion)**: Inject interfaces, never concrete implementations. The host app can decorate or replace any service.
---
## 2. Contract Pattern
- `src/Contract/`: public interfaces of the bundle (BC-safe API)
- Internal services implement these interfaces
- Host app type-hints on interfaces, not concrete classes
- Only interfaces, events, and DTOs from `Contract/` are part of the public API (semver)
- Changes to `Contract/` require a major version bump
---
## 3. Code Style
- `declare(strict_types=1)` at the top of every PHP file
- **`final readonly class`** for: services, handlers, processors, voters, listeners, DTOs
- Exceptions to `final readonly`: Doctrine entities (not `readonly`), bundle class (not `final`), classes explicitly designed for inheritance (documented)
- PHP 8 attributes for routing/validation: `#[Route]`, `#[Assert\...]`. Never `@annotations`.
- **No attributes for service/tag registration** (`#[AsEventListener]`, `#[AutoconfigureTag]`, `#[AsTwigComponent]`, etc.) — in a bundle, all service registration goes through `loadExtension()` or XML config.
- Native function prefix: `\count()`, `\sprintf()`, `\in_array()`, `\array_map()`
- `match` over `switch` when applicable
- Early return to reduce nesting
- Constructor injection only (promoted properties: `private readonly`)
- Never `new Service()`: always dependency injection
- No business logic in controllers, entities, or events
- No hardcoded parameters: use `configure()` + env vars
---
## 4. Scaffolding a New Bundle
### Directory Structure
```
acme-blog-bundle/
├── assets/ # Frontend source (JS, CSS, Stimulus controllers)
│ ├── controllers/ # Stimulus controllers (*_controller.js)
│ ├── dist/ # Pre-compiled assets
│ ├── styles/ # CSS/SCSS source files
│ └── package.json # UX metadata for Stimulus discovery
├── config/
│ ├── definition.php # Complex config tree (optional)
│ ├── doctrine/ # XML mapping files (Entity.orm.xml)
│ ├── routes.yaml # Bundle routes
│ └── services.xml # Service definitions (XML preferred)
├── docs/
│ └── index.md
├── src/
│ ├── AcmeBlogBundle.php # Main bundle class (extends AbstractBundle)
│ ├── Contract/ # Public interfaces (BC-safe API)
│ ├── Controller/
│ ├── DependencyInjection/
│ │ └── CompilerPass/
│ ├── Entity/
│ ├── Event/ # Bundle events for extensibility
│ ├── EventSubscriber/
│ ├── Repository/
│ ├── Service/
│ └── Twig/
│ ├── Components/
│ └── Extension/
├── templates/
│ └── components/
├── tests/
│ ├── Functional/
│ ├── Integration/
│ └── Unit/
├── translations/
│ └── AcmeBlogBundle.en.xlf
├── .editorconfig
├── .gitignore
├── .php-cs-fixer.dist.php
├── CHANGELOG.md
├── composer.json
├── deptrac.yaml
├── grumphp.yml
├── infection.json5
├── LICENSE
├── Makefile
├── phpstan.neon.dist
├── phpunit.xml.dist
└── README.md
```
### Naming Conventions
Enforced by the framework — breaking them causes resolution failures:
- **Bundle class**: `{Vendor}{Name}Bundle` → `AcmeBlogBundle`
- **Namespace**: `{Vendor}\{Name}Bundle` → `Acme\BlogBundle`
- **Config alias** (auto-derived, snake_case): `acme_blog`
- **Service prefix**: `acme_blog.` for all service IDs
- **Route name prefix**: `acme_blog_` for all route names
- **Translation domain**: `AcmeBlogBundle`
- **Twig namespace**: `@AcmeBlog` (suffix `Bundle` stripped)
### composer.json
```json
{
"name": "acme/blog-bundle",
"type": "symfony-bundle",
"description": "A concise description",
"license": "MIT",
"require": {
"php": ">=8.2",
"symfony/config": "^7.0 || ^8.0",
"symfony/dependency-injection": "^7.0 || ^8.0",
"symfony/framework-bundle": "^7.0 || ^8.0",
"symfony/http-kernel": "^7.0 || ^8.0"
},
"require-dev": {
"deptrac/deptrac-src": "^2.0",
"friendsofphp/php-cs-fixer": "^3.0",
"infection/infection": "^0.29",
"nyholm/symfony-bundle-test": "^3.0",
"phpro/grumphp": "^2.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^10.0 || ^11.0",
"symfony/phpunit-bridge": "^7.0 || ^8.0"
},
"autoload": { "psr-4": { "Acme\\BlogBundle\\": "src/" } },
"autoload-dev": { "psr-4": { "Acme\\BlogBundle\\Tests\\": "tests/" } },
"extra": { "symfony": { "require": "^7.0 || ^8.0" } }
}
```
Rules: only declare actual dependencies, `"type": "symfony-bundle"`, every PHP extension in `require`, never embed vendor code in `src/`, always `^7.0 || ^8.0` for Symfony deps.
---
## 5. The AbstractBundle Class
Always use `AbstractBundle` for new bundles (Symfony 6.1+). Method execution order:
1. `prependExtension()` — FIRST, before config resolution
2. `configure()` — defines the schema tree
3. `loadExtension()` — AFTER config merged+validated
4. `build()` — registers compiler passes
See the complete template in the `scaffold_bundle.php` script.
---
## 6. Service Definitions (XML)
XML preferred (no Yaml component dependency). Defaults: `autowire="true" autoconfigure="true" public="false"`.
Rules:
- Never public by default
- Always prefix service IDs: `acme_blog.service_name`
- Use interface aliases for type-hinting
- `registerForAutoconfiguration()` for custom interfaces
---
## 7. Controllers in Bundles
Never extend `AbstractController`. Inject specific services via constructor.
---
## 8. Routing
Use YAML/XML files in `config/routes.yaml`, not PHP attributes. Prefix all route names: `acme_blog_`.
---
## 9. Translations (XLIFF)
**Every user-facing string must be translated** — no hardcoded text in templates, controllers, forms, validators, or flash messages.
- XLIFF (.xlf) mandatory. Own domain (`AcmeBlogBundle`). Never overwrite other domains.
- Extract **all** strings: Twig (`{% trans %}`, `|trans`), form labels/help/placeholders, constraint messages, controller flash messages, email subjects/bodies, error pages
- Always provide at least `en` and `fr` locales
- Form labels: always explicit `'label' => 'form.field_name'`, never rely on auto-generated labels
- Validator messages: always use translation keys (`'message' => 'validation.field.constraint'`)
- Run `translation:extract` regularly to catch missing keys
---
## 10. Testing
Use `nyholm/symfony-bundle-test`. Always generate at least one integration test. Run `make quality` after every change.
---
## 11. Configurability
Every feature of a bundle must be **activatable, deactivatable, and configurable** via `configure()`:
- Enable/disable any feature (Doctrine, controllers, Twig components, listeners, etc.) via a boolean config key
- Override Doctrine entity classes and repository classes in config (see `symfony-bundle-doctrine` skill)
- Customize any default value (table prefix, translation domain, route prefix, etc.)
- The host app must never need to fork the bundle to customize behavior
## 12. Anti-Patterns Checklist
| Anti-Pattern | Impact |
|---|---|
| Extending `AbstractController` | Couples to entire FrameworkBundle |
| YAML for service definitions | Forces Yaml dependency |
| Embedding vendor code in `src/` | Breaks autoloading, security audits |
| Hardcoded parameter values | Host can't customize |
| Public services by default | Pollutes host container |
| Missing service ID prefix | Collisions |
| `new Service()` instead of DI | Untestable, unconfigurable |
| Setter/property injection | Hidden dependencies |
| Business logic in controller/entity | SRP violation |
| Service class without `final` | Fragile inheritance |
| `mixed` without justification | PHPStan level 9 violation |
| Hardcoded entity/repository classes | Host can't customize |
| Non-translatable user-facing strings | Breaks i18n |
| Features that cannot be disabled | Host can't tailor bundle |
---
## 13. Generating a Bundle
1. Ask for: vendor name, purpose, Symfony components, Doctrine?, assets?, Twig components?
2. Scaffold full directory structure (use [scaffold_bundle.php](scaffold_bundle.php))
3. Generate all config files + quality tooling
4. If Doctrine: read `symfony-bundle-doctrine` skill
5. If frontend: read `symfony-bundle-ux` skill
6. If Flex: read `symfony-bundle-flex` skill
7. Always generate integration test
8. Run `make quality` to validate
## Additional Resources
- For compiler passes, events, voters, cache warmers, and semver contracts, see [advanced-patterns.mdc](advanced-patterns.mdc)
- To scaffold a bundle from the command line, run [scaffold_bundle.php](scaffold_bundle.php)
- For quality tooling (Makefile, PHPStan 9, GrumPHP, Infection, Deptrac, CI), read the `symfony-bundle-quality` skill
This skill covers the full lifecycle of creating a modern Symfony bundle: project scaffolding, AbstractBundle configuration, dependency injection, service registration, routing, translations, testing, and distribution.
For frontend assets (AssetMapper, Stimulus, Twig Components, Live Components), see the symfony-bundle-ux skill.
For Doctrine entity modeling in bundles, see the symfony-bundle-doctrine skill.
For Flex recipes and distribution, see the symfony-bundle-flex skill.
For quality tooling (Makefile, PHPStan, GrumPHP, Infection, CI), see the symfony-bundle-quality skill.
A bundle is code reusable and modifiable by the entire community. SOLID is not optional.
final readonly class on concrete classes forces extension by composition, not inheritance.instanceof checks on concrete implementations internally.src/Contract/: public interfaces of the bundle (BC-safe API)Contract/ are part of the public API (semver)Contract/ require a major version bumpdeclare(strict_types=1) at the top of every PHP filefinal readonly class for: services, handlers, processors, voters, listeners, DTOsfinal readonly: Doctrine entities (not readonly), bundle class (not final), classes explicitly designed for inheritance (documented)#[Route], #[Assert\...]. Never @annotations.#[AsEventListener], #[AutoconfigureTag], #[AsTwigComponent], etc.) — in a bundle, all service registration goes through loadExtension() or XML config.\count(), \sprintf(), \in_array(), \array_map()match over switch when applicableprivate readonly)new Service(): always dependency injectionconfigure() + env varsacme-blog-bundle/
├── assets/ # Frontend source (JS, CSS, Stimulus controllers)
│ ├── controllers/ # Stimulus controllers (*_controller.js)
│ ├── dist/ # Pre-compiled assets
│ ├── styles/ # CSS/SCSS source files
│ └── package.json # UX metadata for Stimulus discovery
├── config/
│ ├── definition.php # Complex config tree (optional)
│ ├── doctrine/ # XML mapping files (Entity.orm.xml)
│ ├── routes.yaml # Bundle routes
│ └── services.xml # Service definitions (XML preferred)
├── docs/
│ └── index.md
├── src/
│ ├── AcmeBlogBundle.php # Main bundle class (extends AbstractBundle)
│ ├── Contract/ # Public interfaces (BC-safe API)
│ ├── Controller/
│ ├── DependencyInjection/
│ │ └── CompilerPass/
│ ├── Entity/
│ ├── Event/ # Bundle events for extensibility
│ ├── EventSubscriber/
│ ├── Repository/
│ ├── Service/
│ └── Twig/
│ ├── Components/
│ └── Extension/
├── templates/
│ └── components/
├── tests/
│ ├── Functional/
│ ├── Integration/
│ └── Unit/
├── translations/
│ └── AcmeBlogBundle.en.xlf
├── .editorconfig
├── .gitignore
├── .php-cs-fixer.dist.php
├── CHANGELOG.md
├── composer.json
├── deptrac.yaml
├── grumphp.yml
├── infection.json5
├── LICENSE
├── Makefile
├── phpstan.neon.dist
├── phpunit.xml.dist
└── README.md
Enforced by the framework — breaking them causes resolution failures:
{Vendor}{Name}Bundle → AcmeBlogBundle{Vendor}\{Name}Bundle → Acme\BlogBundleacme_blogacme_blog. for all service IDsacme_blog_ for all route namesAcmeBlogBundle@AcmeBlog (suffix Bundle stripped){
"name": "acme/blog-bundle",
"type": "symfony-bundle",
"description": "A concise description",
"license": "MIT",
"require": {
"php": ">=8.2",
"symfony/config": "^7.0 || ^8.0",
"symfony/dependency-injection": "^7.0 || ^8.0",
"symfony/framework-bundle": "^7.0 || ^8.0",
"symfony/http-kernel": "^7.0 || ^8.0"
},
"require-dev": {
"deptrac/deptrac-src": "^2.0",
"friendsofphp/php-cs-fixer": "^3.0",
"infection/infection": "^0.29",
"nyholm/symfony-bundle-test": "^3.0",
"phpro/grumphp": "^2.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^10.0 || ^11.0",
"symfony/phpunit-bridge": "^7.0 || ^8.0"
},
"autoload": { "psr-4": { "Acme\\BlogBundle\\": "src/" } },
"autoload-dev": { "psr-4": { "Acme\\BlogBundle\\Tests\\": "tests/" } },
"extra": { "symfony": { "require": "^7.0 || ^8.0" } }
}
Rules: only declare actual dependencies, "type": "symfony-bundle", every PHP extension in require, never embed vendor code in src/, always ^7.0 || ^8.0 for Symfony deps.
Always use AbstractBundle for new bundles (Symfony 6.1+). Method execution order:
prependExtension() — FIRST, before config resolutionconfigure() — defines the schema treeloadExtension() — AFTER config merged+validatedbuild() — registers compiler passesSee the complete template in the scaffold_bundle.php script.
XML preferred (no Yaml component dependency). Defaults: autowire="true" autoconfigure="true" public="false".
Rules:
acme_blog.service_nameregisterForAutoconfiguration() for custom interfacesNever extend AbstractController. Inject specific services via constructor.
Use YAML/XML files in config/routes.yaml, not PHP attributes. Prefix all route names: acme_blog_.
Every user-facing string must be translated — no hardcoded text in templates, controllers, forms, validators, or flash messages.
AcmeBlogBundle). Never overwrite other domains.{% trans %}, |trans), form labels/help/placeholders, constraint messages, controller flash messages, email subjects/bodies, error pagesen and fr locales'label' => 'form.field_name', never rely on auto-generated labels'message' => 'validation.field.constraint')translation:extract regularly to catch missing keysUse nyholm/symfony-bundle-test. Always generate at least one integration test. Run make quality after every change.
Every feature of a bundle must be activatable, deactivatable, and configurable via configure():
symfony-bundle-doctrine skill)| Anti-Pattern | Impact |
|---|---|
Extending AbstractController |
Couples to entire FrameworkBundle |
| YAML for service definitions | Forces Yaml dependency |
Embedding vendor code in src/ |
Breaks autoloading, security audits |
| Hardcoded parameter values | Host can't customize |
| Public services by default | Pollutes host container |
| Missing service ID prefix | Collisions |
new Service() instead of DI |
Untestable, unconfigurable |
| Setter/property injection | Hidden dependencies |
| Business logic in controller/entity | SRP violation |
Service class without final |
Fragile inheritance |
mixed without justification |
PHPStan level 9 violation |
| Hardcoded entity/repository classes | Host can't customize |
| Non-translatable user-facing strings | Breaks i18n |
| Features that cannot be disabled | Host can't tailor bundle |
symfony-bundle-doctrine skillsymfony-bundle-ux skillsymfony-bundle-flex skillmake quality to validatesymfony-bundle-quality skillHow can I help you explore Laravel packages today?