Symfony bundle conventions, SOLID principles, Contract pattern, final readonly architecture
php artisan boost:add-skill symkit/redirect-bundle
Save this content to: .cursor/rules/symfony-bundle.mdc
# Symfony Bundle Conventions
## Architecture
- `final readonly class` for all services, handlers, processors, voters, listeners, DTOs
- Exceptions: Doctrine entities (not `readonly`), bundle class (not `final`), classes documented for inheritance
- Extension points: interfaces, events, compiler passes, decorators — never inheritance
## AbstractBundle (Symfony 7+/8+)
- Always extend `AbstractBundle`, never `Bundle` with separate Extension/Configuration classes
- `configure()` for config tree, `loadExtension()` for container loading, `prependExtension()` for prepending other bundles
- `DependencyInjection/` folder is unnecessary with AbstractBundle — all DI logic lives in the bundle class
- `$this->getPath()` for bundle root path (replaces `\dirname(__DIR__, 2)`)
- Translations auto-discovered from `<BundlePath>/translations/` (XLIFF format)
## SOLID
- **S**: One class, one responsibility. No "god service".
- **O**: Extensible via events/interfaces/decorators. `final readonly` forces composition.
- **L**: Interfaces are substitutable. No `instanceof` on concrete implementations.
- **I**: Segregated interfaces. One interface = one precise contract.
- **D**: Inject interfaces, never concrete implementations.
## Contract Pattern
- `src/Contract/`: public interfaces of the bundle (BC-safe API)
- Services implement Contract interfaces. Host app type-hints on interfaces.
- Only `Contract/`, events, and DTOs are part of the public semver API.
## Naming
- Bundle class: `{Vendor}{Name}Bundle`, namespace `{Vendor}\{Name}Bundle`
- Config alias (auto-derived, snake_case): service prefix `alias.`, route prefix `alias_`
- Translation domain: `{Vendor}{Name}Bundle` in XLIFF
## Structure
- `src/Contract/`, `src/Event/` always present when applicable
- XML for service definitions when needed (no Yaml dependency). Defaults: `autowire=true autoconfigure=true public=false`
- `translations/` at bundle root for XLIFF files (auto-discovered by Symfony)
- Never extend `AbstractController` — inject specific services
## README
The `README.md` must always include the following badges at the top (replace `{vendor}/{repo}` with the actual repository name):
```markdown
[](https://github.com/{vendor}/{repo}/actions)
[](https://packagist.org/packages/{vendor}/{repo})
[](https://phpstan.org/)
```
## Services (contexte bundle)
- Dans un bundle, **ne pas utiliser les attributs** pour déclarer les services (tags, listeners, composants Twig, etc.).
- Tout enregistrement de services, tags et options doit se faire **en configuration** : dans `loadExtension()` du bundle (AbstractBundle) ou dans les fichiers XML de définition de services.
- Exemples à éviter dans le code du bundle : `#[AsEventListener]`, `#[AsTwigComponent]`, `#[AsLiveComponent]`, tags sur les classes. À la place : appels à `$container->register()`, `->addTag()`, et déclarations explicites dans l’extension ou le XML.
## Dependencies
- Symfony packages: always `^7.0 || ^8.0` (semver, never `7.*|8.*`)
- Only declare actual dependencies. Optional deps in `suggest`.
- Never embed vendor code in `src/`
- Composer `allow-plugins`: configure `infection/extension-installer` and `phpro/grumphp` in `composer.json`
## Configurability
Every feature of a bundle must be **activatable, deactivatable, and configurable** via the bundle's `configure()` tree. The host app must be able to:
- Enable/disable any feature (Doctrine, controllers, Twig components, listeners, etc.) via a boolean config key
- Override Doctrine **entity classes** and **repository classes** in config — the bundle must never hardcode its own entity/repository classes in services or queries; always resolve them from configuration
- Customize any default value (table prefix, translation domain, route prefix, etc.)
Example pattern in `configure()`:
```php
->arrayNode('entity')
->addDefaultsIfNotSet()
->children()
->scalarNode('article_class')->defaultValue(Article::class)->end()
->scalarNode('article_repository_class')->defaultValue(ArticleRepository::class)->end()
->end()
->end()
```
Then in `loadExtension()`, bind these classes as parameters and inject them into services. Never use `Article::class` directly in service code — always use the configured class.
## Translations
Every user-facing string must be translated — no hardcoded text in templates, controllers, forms, validators, or flash messages.
- Extract **all** strings: Twig templates (`{% trans %}`, `|trans`), form labels/help/placeholders, constraint messages, controller flash messages, error pages, email subjects/bodies
- XLIFF format in `translations/` at bundle root, domain `{Vendor}{Name}Bundle`
- Always provide at least `en` and `fr` locales
- Run `translation:extract` regularly to catch missing keys
- Form labels: always explicit `'label' => 'form.field_name'`, never rely on auto-generated labels
- Validator messages: always use translation keys (`'message' => 'validation.field.constraint'`)
## Anti-Patterns
- Using `Bundle` instead of `AbstractBundle` with separate Extension/Configuration classes
- **Using attributes for service/tag registration in bundle code** — use config/loadExtension() instead
- `new Configuration()` or `new Service()` — always use DI or AbstractBundle built-in methods
- Public services by default, missing service ID prefix, YAML services
- Setter injection, business logic in controller/entity
- Hardcoded parameters instead of `configure()` + env vars
- Symfony constraints `7.*|8.*` instead of `^7.0 || ^8.0`
- Hardcoded entity/repository classes — always configurable via bundle config
- Non-translatable user-facing strings — everything must go through the translation system
- Features that cannot be disabled — every feature must have a config toggle
## Commits
When committing, never reference the agent or any co-author (no Co-authored-by, Signed-off-by from the AI, or similar). Commit messages and metadata must describe only the change, with no self-attribution. Never use `git commit --trailer` or add Co-authored-by to commits.
## Cleanup — Legacy commit-msg hook
If the project contains any of the following legacy artefacts, remove them:
- `scripts/git-hooks/commit-msg` — delete the file (and `scripts/git-hooks/` directory if empty)
- `install-hooks` target in the Makefile — remove the target and its `.PHONY` entry
- Any `make install-hooks` mention in README or other documentation
These artefacts are obsolete and must be cleaned up when encountered.
## After Every Change — Makefile First
Always use Makefile targets. Never call `vendor/bin/` directly.
- `make cs-fix` — fix code style
- `make phpstan` — static analysis
- `make lint` — composer validate --strict
- `make test` — run tests
- `make quality` — full pipeline (cs-check + phpstan + deptrac + lint + test + infection)
- `make ci` — security-check + quality
## Available Skills
- `symfony-bundle-core`: architecture, scaffolding
- `symfony-bundle-ux`: AssetMapper, Stimulus, Twig/Live Components
- `symfony-bundle-doctrine`: entities, XML mapping
- `symfony-bundle-flex`: Flex recipes
- `symfony-bundle-quality`: Makefile, PHPStan, GrumPHP, Infection, Deptrac, CI
final readonly class for all services, handlers, processors, voters, listeners, DTOsreadonly), bundle class (not final), classes documented for inheritanceAbstractBundle, never Bundle with separate Extension/Configuration classesconfigure() for config tree, loadExtension() for container loading, prependExtension() for prepending other bundlesDependencyInjection/ folder is unnecessary with AbstractBundle — all DI logic lives in the bundle class$this->getPath() for bundle root path (replaces \dirname(__DIR__, 2))<BundlePath>/translations/ (XLIFF format)final readonly forces composition.instanceof on concrete implementations.src/Contract/: public interfaces of the bundle (BC-safe API)Contract/, events, and DTOs are part of the public semver API.{Vendor}{Name}Bundle, namespace {Vendor}\{Name}Bundlealias., route prefix alias_{Vendor}{Name}Bundle in XLIFFsrc/Contract/, src/Event/ always present when applicableautowire=true autoconfigure=true public=falsetranslations/ at bundle root for XLIFF files (auto-discovered by Symfony)AbstractController — inject specific servicesThe README.md must always include the following badges at the top (replace {vendor}/{repo} with the actual repository name):
[](https://github.com/{vendor}/{repo}/actions)
[](https://packagist.org/packages/{vendor}/{repo})
[](https://phpstan.org/)
loadExtension() du bundle (AbstractBundle) ou dans les fichiers XML de définition de services.#[AsEventListener], #[AsTwigComponent], #[AsLiveComponent], tags sur les classes. À la place : appels à $container->register(), ->addTag(), et déclarations explicites dans l’extension ou le XML.^7.0 || ^8.0 (semver, never 7.*|8.*)suggest.src/allow-plugins: configure infection/extension-installer and phpro/grumphp in composer.jsonEvery feature of a bundle must be activatable, deactivatable, and configurable via the bundle's configure() tree. The host app must be able to:
Example pattern in configure():
->arrayNode('entity')
->addDefaultsIfNotSet()
->children()
->scalarNode('article_class')->defaultValue(Article::class)->end()
->scalarNode('article_repository_class')->defaultValue(ArticleRepository::class)->end()
->end()
->end()
Then in loadExtension(), bind these classes as parameters and inject them into services. Never use Article::class directly in service code — always use the configured class.
Every user-facing string must be translated — no hardcoded text in templates, controllers, forms, validators, or flash messages.
{% trans %}, |trans), form labels/help/placeholders, constraint messages, controller flash messages, error pages, email subjects/bodiestranslations/ at bundle root, domain {Vendor}{Name}Bundleen and fr localestranslation:extract regularly to catch missing keys'label' => 'form.field_name', never rely on auto-generated labels'message' => 'validation.field.constraint')Bundle instead of AbstractBundle with separate Extension/Configuration classesnew Configuration() or new Service() — always use DI or AbstractBundle built-in methodsconfigure() + env vars7.*|8.* instead of ^7.0 || ^8.0When committing, never reference the agent or any co-author (no Co-authored-by, Signed-off-by from the AI, or similar). Commit messages and metadata must describe only the change, with no self-attribution. Never use git commit --trailer or add Co-authored-by to commits.
If the project contains any of the following legacy artefacts, remove them:
scripts/git-hooks/commit-msg — delete the file (and scripts/git-hooks/ directory if empty)install-hooks target in the Makefile — remove the target and its .PHONY entrymake install-hooks mention in README or other documentationThese artefacts are obsolete and must be cleaned up when encountered.
Always use Makefile targets. Never call vendor/bin/ directly.
make cs-fix — fix code stylemake phpstan — static analysismake lint — composer validate --strictmake test — run testsmake quality — full pipeline (cs-check + phpstan + deptrac + lint + test + infection)make ci — security-check + qualitysymfony-bundle-core: architecture, scaffoldingsymfony-bundle-ux: AssetMapper, Stimulus, Twig/Live Componentssymfony-bundle-doctrine: entities, XML mappingsymfony-bundle-flex: Flex recipessymfony-bundle-quality: Makefile, PHPStan, GrumPHP, Infection, Deptrac, CIHow can I help you explore Laravel packages today?