Symfony standards — start here
Short on purpose. Detect the project, apply the five rules, load the skill that matches
the work.
Step 0 — read the project before writing to it
Never assume versions or conventions. Once per session:
php -v | head -1
php -r '$l=json_decode(file_get_contents("composer.lock"),true);
foreach(array_merge($l["packages"],$l["packages-dev"]??[]) as $p)
if(preg_match("#^(symfony/(framework-bundle|serializer|form|security-bundle|object-mapper|messenger|scheduler|lock|rate-limiter|asset-mapper|ux-live-component)|doctrine/orm|doctrine/doctrine-fixtures-bundle|phpunit/phpunit|twig/twig)$#",$p["name"]))
echo str_pad($p["name"],40)." ".$p["version"]."\n";'
ls src/ config/packages/
What it tells you:
- Symfony and PHP versions → which attributes exist. Every skill states what to write
instead when one is unavailable; the rule never disappears, only the means change.
- Which optional components are installed → what you can lean on without asking.
Anything missing gets proposed, never installed silently.
- The existing layout of
src/ → the conventions to follow. If the project already
groups code by domain, or puts DTOs somewhere else, follow it. Boundaries between
layers are not negotiable; folder names are.
vendor/ outranks everything written in these skills. Reading the source of an
attribute takes a second and is never wrong; memory and blog posts frequently are.
The five rules
They apply everywhere, whatever the task.
1. Write the test first, and watch it fail. A test that was never red can be green
for the wrong reason — inverted assertion, permissive mock, never executed at all. Where
red-first is impossible, break the code and confirm the test notices.
2. A controller translates, it does not decide. HTTP in, service call, response out.
No business rule, no persistence.
3. No DQL, QueryBuilder or SQL outside a repository. Queries are named after the
caller's intent, not the mechanism. A service that builds a query cannot be tested
without a database.
4. DTOs at both edges. Input DTOs carry the validation; output DTOs carry the
contract. An entity never reaches a template or a JSON response.
5. Every business rule lives in a service. Entities hold data and mapping; they hold
no rules. That is the direct consequence of using maker-format entities, and it is
deliberate.
If a change seems too small to deserve a test, it is still covered by rule 1. If a rule
seems wrong for the case at hand, say so and explain why rather than quietly bending it.
Two tiers: the core, and what the project has to earn
The five rules above are the whole core. They remove decisions and add no artefact:
a project that keeps only them still follows this standard. Everything else in the suite
is on demand — it adds a file, a tool or a ritual per feature, and it is switched on
by a real need, never pre-emptively. Each skill says which tier it is in, right under
its title.
| Tier |
What is in it |
Switched on by |
| Core |
The five rules; the layer contract; Input and Output DTOs; dama/doctrine-test-bundle; PHPStan and php-cs-fixer; the local dev loop |
Nothing — it applies to every Symfony project this suite touches |
| On demand |
Query-count tests; deptrac; Messenger, Scheduler and prioritised queues; application and HTTP caches; correlation ids, health checks, alert sinks; signed URLs and object storage; Live Components and Mercure |
A measured slowness, a second worker, a real queue, a real deploy target, a real upload, a real second developer |
The test for any rule you are about to apply or add: does it remove a decision, or
does it add an artefact? The first kind is free and there can be a hundred of them.
The second kind is paid on every feature, and it needs the trigger in the right-hand
column before it is worth paying.
What the rules do not require
The five rules are read strictly on the boundaries and generously on the ceremony. None
of the following bends a rule; each is the rule applied with judgement:
- A GET with nothing but route parameters needs no Input DTO.
#[MapQueryString]
is for a query string that must be validated, not for show(int $id).
- A
SELECT NEW projection that already matches the contract is the Output DTO.
The Read layer exists for the case where the query's shape and the API's shape
differ; when they do not, there is one class, in src/Dto/Output/.
- One Output DTO per shape, not per endpoint. A list row and a detail view share
the class when their fields are the same.
- A business exception exists only for a failure a caller can actually hit. An
invalid input is a 422 produced by the DTO's constraints, not an exception class.
- A class with no rule gets no unit test of its own. A DTO without normalisation,
a controller, a command, a getter: the functional or smoke test that proves the wiring
is the whole coverage they need. Rule 1 is about behaviour, not about files.
- A read service may hold every read of one resource.
ShelfReader::shelf() and
ShelfReader::book() in one class is the intent; one class per query is not.
Where to go next
| The work is about |
Load |
| Where a class goes, layers, DTOs, services, DI, patterns, why a rule exists |
symfony-yoandev-architecture |
| Writing or fixing tests, fixtures, mocks, the TDD loop |
symfony-yoandev-testing |
| Controllers, routes, request payloads, JSON responses, forms, Twig pages |
symfony-yoandev-http |
| Entities, repositories, queries, relations, migrations |
symfony-yoandev-doctrine |
| Login, permissions, voters, CSRF, tokens, hardening |
symfony-yoandev-security |
| JavaScript, CSS, Stimulus, Turbo, components, AssetMapper |
symfony-yoandev-frontend |
| Background work, emails, queues, scheduled jobs |
symfony-yoandev-async |
| Console commands, imports, cleanup scripts |
symfony-yoandev-console |
| Something is slow, too many queries, caching |
symfony-yoandev-performance |
| PHPStan, deptrac, code style, CI pipeline |
symfony-yoandev-quality |
| Running the project locally, Docker services, the dev server |
symfony-yoandev-local-dev |
| Shipping to production, migrations at deploy, server settings |
symfony-yoandev-deployment |
| Logs, alerting, health checks, knowing what production is doing |
symfony-yoandev-observability |
| Uploaded files: where they go, how they are served, orphans |
symfony-yoandev-storage |
| Deprecations, updating dependencies, moving to a new Symfony major |
symfony-yoandev-upgrade |
Most real tasks touch two or three. A feature request typically means
symfony-yoandev-architecture to decide where the pieces go, symfony-yoandev-testing to start, then
the one matching the surface being built. Load them as you reach them rather than all at
once.
Before handing back
The last two matter as much as the code. A change that works but hides a decision is a
change someone will have to reverse-engineer later.
1---2name: symfony-yoandev-standards3description: Entry point for writing code in a Symfony project: detects the project's versions and conventions, states the five rules that apply to everything, and routes to the specialised skill for the task at hand. Load this FIRST whenever a request touches a Symfony codebase and the right specialised skill is not obvious — a feature described in plain language ("let people rate a book", "make a CRUD", "add this to the admin"), a vague instruction ("refactor this", "clean this up", "fix this bug", "finish this"), a task spanning several areas at once, or the very first change on an unfamiliar project. Also load it before starting anything if you are unsure which of symfony-yoandev-architecture, symfony-yoandev-http, symfony-yoandev-doctrine, symfony-yoandev-testing, symfony-yoandev-security, symfony-yoandev-frontend, symfony-yoandev-async, symfony-yoandev-console, symfony-yoandev-performance, symfony-yoandev-quality, symfony-yoandev-local-dev, symfony-yoandev-deployment, symfony-yoandev-observability, symfony-yoandev-s4---56# Symfony standards — start here78Short on purpose. Detect the project, apply the five rules, load the skill that matches9the work.1011## Step 0 — read the project before writing to it1213Never assume versions or conventions. Once per session:1415```bash16php -v | head -117php -r '$l=json_decode(file_get_contents("composer.lock"),true);18foreach(array_merge($l["packages"],$l["packages-dev"]??[]) as $p)19 if(preg_match("#^(symfony/(framework-bundle|serializer|form|security-bundle|object-mapper|messenger|scheduler|lock|rate-limiter|asset-mapper|ux-live-component)|doctrine/orm|doctrine/doctrine-fixtures-bundle|phpunit/phpunit|twig/twig)$#",$p["name"]))20 echo str_pad($p["name"],40)." ".$p["version"]."\n";'21ls src/ config/packages/22```2324What it tells you:2526- **Symfony and PHP versions** → which attributes exist. Every skill states what to write27 instead when one is unavailable; the rule never disappears, only the means change.28- **Which optional components are installed** → what you can lean on without asking.29 Anything missing gets *proposed*, never installed silently.30- **The existing layout of `src/`** → the conventions to follow. If the project already31 groups code by domain, or puts DTOs somewhere else, follow it. Boundaries between32 layers are not negotiable; folder names are.3334**`vendor/` outranks everything written in these skills.** Reading the source of an35attribute takes a second and is never wrong; memory and blog posts frequently are.3637## The five rules3839They apply everywhere, whatever the task.4041**1. Write the test first, and watch it fail.** A test that was never red can be green42for the wrong reason — inverted assertion, permissive mock, never executed at all. Where43red-first is impossible, break the code and confirm the test notices.4445**2. A controller translates, it does not decide.** HTTP in, service call, response out.46No business rule, no persistence.4748**3. No DQL, QueryBuilder or SQL outside a repository.** Queries are named after the49caller's intent, not the mechanism. A service that builds a query cannot be tested50without a database.5152**4. DTOs at both edges.** Input DTOs carry the validation; output DTOs carry the53contract. An entity never reaches a template or a JSON response.5455**5. Every business rule lives in a service.** Entities hold data and mapping; they hold56no rules. That is the direct consequence of using maker-format entities, and it is57deliberate.5859If a change seems too small to deserve a test, it is still covered by rule 1. If a rule60seems wrong for the case at hand, say so and explain why rather than quietly bending it.6162## Two tiers: the core, and what the project has to earn6364The five rules above are the whole **core**. They remove decisions and add no artefact:65a project that keeps only them still follows this standard. Everything else in the suite66is **on demand** — it adds a file, a tool or a ritual per feature, and it is switched on67by a real need, never pre-emptively. Each skill says which tier it is in, right under68its title.6970| Tier | What is in it | Switched on by |71|---|---|---|72| **Core** | The five rules; the layer contract; Input and Output DTOs; `dama/doctrine-test-bundle`; PHPStan and php-cs-fixer; the local dev loop | Nothing — it applies to every Symfony project this suite touches |73| **On demand** | Query-count tests; deptrac; Messenger, Scheduler and prioritised queues; application and HTTP caches; correlation ids, health checks, alert sinks; signed URLs and object storage; Live Components and Mercure | A measured slowness, a second worker, a real queue, a real deploy target, a real upload, a real second developer |7475The test for any rule you are about to apply or add: **does it remove a decision, or76does it add an artefact?** The first kind is free and there can be a hundred of them.77The second kind is paid on every feature, and it needs the trigger in the right-hand78column before it is worth paying.7980## What the rules do not require8182The five rules are read strictly on the boundaries and generously on the ceremony. None83of the following bends a rule; each is the rule applied with judgement:8485- **A GET with nothing but route parameters needs no Input DTO.** `#[MapQueryString]`86 is for a query string that must be validated, not for `show(int $id)`.87- **A `SELECT NEW` projection that already matches the contract *is* the Output DTO.**88 The Read layer exists for the case where the query's shape and the API's shape89 differ; when they do not, there is one class, in `src/Dto/Output/`.90- **One Output DTO per shape, not per endpoint.** A list row and a detail view share91 the class when their fields are the same.92- **A business exception exists only for a failure a caller can actually hit.** An93 invalid input is a 422 produced by the DTO's constraints, not an exception class.94- **A class with no rule gets no unit test of its own.** A DTO without normalisation,95 a controller, a command, a getter: the functional or smoke test that proves the wiring96 is the whole coverage they need. Rule 1 is about behaviour, not about files.97- **A read service may hold every read of one resource.** `ShelfReader::shelf()` and98 `ShelfReader::book()` in one class is the intent; one class per query is not.99100## Where to go next101102| The work is about | Load |103|---|---|104| Where a class goes, layers, DTOs, services, DI, patterns, why a rule exists | `symfony-yoandev-architecture` |105| Writing or fixing tests, fixtures, mocks, the TDD loop | `symfony-yoandev-testing` |106| Controllers, routes, request payloads, JSON responses, forms, Twig pages | `symfony-yoandev-http` |107| Entities, repositories, queries, relations, migrations | `symfony-yoandev-doctrine` |108| Login, permissions, voters, CSRF, tokens, hardening | `symfony-yoandev-security` |109| JavaScript, CSS, Stimulus, Turbo, components, AssetMapper | `symfony-yoandev-frontend` |110| Background work, emails, queues, scheduled jobs | `symfony-yoandev-async` |111| Console commands, imports, cleanup scripts | `symfony-yoandev-console` |112| Something is slow, too many queries, caching | `symfony-yoandev-performance` |113| PHPStan, deptrac, code style, CI pipeline | `symfony-yoandev-quality` |114| Running the project locally, Docker services, the dev server | `symfony-yoandev-local-dev` |115| Shipping to production, migrations at deploy, server settings | `symfony-yoandev-deployment` |116| Logs, alerting, health checks, knowing what production is doing | `symfony-yoandev-observability` |117| Uploaded files: where they go, how they are served, orphans | `symfony-yoandev-storage` |118| Deprecations, updating dependencies, moving to a new Symfony major | `symfony-yoandev-upgrade` |119120Most real tasks touch two or three. A feature request typically means121`symfony-yoandev-architecture` to decide where the pieces go, `symfony-yoandev-testing` to start, then122the one matching the surface being built. Load them as you reach them rather than all at123once.124125## Before handing back126127- [ ] The tests were written first, seen red, and now pass — and you ran them.128- [ ] `vendor/bin/phpunit` result reported as it actually is, failures included.129- [ ] No business rule in a controller, no query outside a repository, no entity crossing130 an edge.131- [ ] Anything you had to decide for the user is stated plainly, not buried.132- [ ] Anything you could not do is named, rather than silently dropped.133134The last two matter as much as the code. A change that works but hides a decision is a135change someone will have to reverse-engineer later.