Frontend
Tier: core the moment there is a front end — AssetMapper, Stimulus and the no-bundler rule. Live Components, Mercure and every UX package are on demand — add them when the need appears, as the tables below say.
Twig, Stimulus, Turbo and Symfony UX, served by AssetMapper. No bundler.
symfony console importmap:require chart.js # add a JS dependency
symfony console debug:asset-map # what is actually mapped
symfony console importmap:audit # vulnerable JS dependencies
symfony console asset-map:compile # deploy step — see below
The price of no bundler, stated once
AssetMapper serves your files as ES modules with an import map. There is no
compilation step, so there is no JSX, no Vue or Svelte single-file components, no
TypeScript, no advanced transpilation, and no tree-shaking of a dependency you only
use a corner of.
That is the deal, and it is not renegotiated per feature. If a request genuinely needs
a JavaScript framework with a build step, say it is out of scope rather than quietly
introducing a bundler — a bundler is a second toolchain, a second lockfile, a second CI
job and a second thing that breaks on a Node upgrade. (For orientation on an existing
project only: Webpack Encore is now Symfony's legacy option and symfony/reprise
is the official Vite/Rsbuild integration. Neither belongs in a project already on
AssetMapper.)
The production trap
This is the part people get wrong, and it is why "AssetMapper is slow" is a widespread
belief. AssetMapper concatenates nothing — a page pulls dozens of small files. That
is fine, better than a bundle for cache granularity, under two conditions:
- HTTP/2 or HTTP/3. Over HTTP/1.1 the browser opens six connections and queues the
rest; many small files then lose to one bundle, decisively. HTTP/2 is a
prerequisite, not an optimisation.
symfony server:start gives it locally,
Caddy/FrankenPHP gives it in production; a naked HTTP/1.1 nginx does not.
- Compression. Each file is small; the wins come from brotli/zstd/gzip. Since
Symfony 7.3,
framework.asset_mapper.precompress: true writes .br / .zst / .gz
next to each compiled asset for the web server to serve directly. On 6.4–7.2 there is
no such option — compress at the web-server level. The rule does not change, only the
means.
And the deploy step that decides whether any of this matters:
php bin/console asset-map:compile
Without it, framework.asset_mapper.server picks up the slack and serves every asset
through the PHP process — a full kernel boot per image, per stylesheet, per module.
The site works, which is exactly the problem: nobody notices until the traffic does.
(The rest of the deploy sequence belongs to symfony-yoandev-deployment.)
Adding a JavaScript dependency
symfony console importmap:require chart.js # pin it in importmap.php
symfony console importmap:install # restore assets/vendor/ from it
symfony console importmap:outdated # what has moved
symfony console importmap:audit # known vulnerabilities
importmap:audit belongs in CI, next to composer audit. With no npm anywhere in
the loop, nothing else will ever report that a pinned JavaScript package has a
published advisory. It is the most-forgotten check on this stack.
Where behaviour lives: follow the state
One question decides it, every time: where does the state live?
| State lives… |
Use |
Because |
| In the browser only |
Stimulus controller |
Nothing to ask the server: a dropdown open/closed, copy-to-clipboard, a character counter, drag-reorder before saving |
| On the server, remembered from one interaction to the next |
Live Component |
The server owns it and must re-render with it: a filtered book list, a search, a form validated as you type, a paginated table |
| On the server, with nothing to remember |
A controller returning a Turbo Stream |
One submission, several zones to refresh, no value carried between round trips — see the arbitration below |
| Nowhere — it is rendering |
Twig Component |
A function from props to HTML: a badge, a book card, a rating display, a modal shell |
The failure modes this prevents are specific: a Stimulus controller that filters in
JavaScript duplicates a rule that already lives in a repository, and the two drift; a
Live Component used for a visual toggle sends an HTTP request to open a menu; a Twig
Component given mutable state grows a re-render mechanism nobody asked for.
Live Component, Stream response, pushed Stream
Three tools update part of a page from the server. One question arbitrates, and it is
answerable without taste:
Is there state to remember between two interactions?
- Yes → Live Component. The state is the component.
- No, and the user acted → a Turbo Stream returned as the response to that
submission.
- No, and the user did not act → a Turbo Stream pushed over Mercure.
| The interaction |
Tool |
Why that one |
| A filter that remembers what it is filtering on, a search refining as you type, a form validated field by field, a multi-step wizard |
Live Component |
The value must survive the round trip and come back rendered. That is exactly what a serialised LiveProp buys |
| A form POST after which several disjoint zones must change — the list gains a row, the counter goes up, a flash appears |
Turbo Stream response (text/vnd.turbo-stream.html) |
Nothing to remember: one submission, one response, several targets. No component class, no state in the DOM, one round trip |
| A notification, a change made by another user, progress on an async task |
Turbo Stream pushed over Mercure |
Nobody clicked. There is no request to answer, so the server has to open one |
The reasoning behind the first row: a Live Component round trip returns the component's
own rendered state — no target ids to name, no fragment template to keep in step with
the full-page render, props preserved between interactions. That machinery costs a
component class, a lifecycle to understand and an HTTP request per interaction, and it
only pays when there is state for it to carry. A component whose props are identical on
every render is a controller with extra steps.
The reasoning behind the second: answering a form submission with a stream is Turbo's own
documented primary usage, and it is stateless by construction. You name the targets and
you write the fragments — that is the price — but there is nothing to serialise into the
DOM, nothing to rehydrate and nothing to remount. Reach for it as the default as soon
as one submission must touch two zones that a single Drive navigation or a single frame
cannot cover together. The controller side is a request format and a template:
references/turbo-and-stimulus.md has the worked example.
The third row is the unchanged one, and the expensive one: a Mercure hub is another
process to run, monitor and secure, with its own JWT configuration and reachable from
the browser. "Add a live notification" is an infrastructure decision, not a template
change. Say so before proposing it.
Two warnings survive the arbitration, because they are about the tools rather than about
the choice between them:
- A Live Component remounts on every interaction. Constructor,
mount(), prop
rehydration, full render — per keystroke. An expensive LiveProp, or a render path
that fans out into a dozen queries, does not make the page slow, it makes it unusable.
- A
#[LiveAction] is a publicly postable endpoint. The route is
ux_live_component and anyone can post to it; the fact that its button only renders
for admins protects nothing. It needs #[IsGranted] exactly as a controller action
does.
Components take DTOs, never entities
<twig:BookCard :book="book" /> {# book is a BookCardView, not a Book entity #}
A Twig template holding a Doctrine entity triggers lazy loading from inside rendering:
book.author.name in a loop is an N+1 nobody sees in the code, and
book.ratings|length hydrates every rating row to count them. Those queries happen in a
template, far from any repository, invisible to a service-level test. Pass a DTO — a
read model built by the service, with exactly the fields the component renders. For a
Live Component it is doubly binding: a LiveProp is serialised into the DOM and
rehydrated on every interaction, so an entity there means an ORM round-trip per
keystroke.
The Live Component trap: every interaction is an HTTP request and the component is
re-instantiated — mount() runs again, props rehydrate, dependencies are re-injected,
anything held in a plain property is gone. Treat "how many queries does one keystroke
cost" as a real question. Details: references/components.md.
Turbo
ux-turbo is three things with three different scopes:
- Drive — intercepts links and forms, fetches the page, swaps the
<body>. On by
default once installed.
- Frames —
<turbo-frame id="…">; a link inside it replaces only it.
- Streams — fragments (
append, replace, remove, …) applied to targets. They
reach the page two ways: as the response to a form submission (Turbo asks for
text/vnd.turbo-stream.html on every non-GET submission, so the controller only has
to answer in that format), or pushed over Mercure with nobody interacting.
Drive changes the page lifecycle, and that is what it breaks. DOMContentLoaded
fires once, on the first load, and never again — any script assuming it runs per page
stops working after the first navigation. Turbo also compares <head> between
navigations, so anything in there that changes per request (a nonce, a timestamp) forces
a full reload. The fix is not a workaround, it is the rule below: behaviour lives in a
Stimulus controller, whose connect() runs on every insertion, Turbo swaps included.
See references/turbo-and-stimulus.md.
Stimulus
- One controller per behaviour, named after what it does, not where it sits:
clipboard_controller.js, character_counter_controller.js — not
book_page_controller.js.
- No inline
<script> in a template. It runs once, does not survive a Turbo
navigation, cannot be reused, and is invisible to importmap:audit. The one
exception is a <script type="application/json"> data island read by a controller.
- No business rule in JavaScript. A threshold, a price, an eligibility check
decided client-side will diverge from the server's — and the server's is the one
that counts. JavaScript may display a rule; it may not own one.
Tailwind, without Node
composer require symfonycasts/tailwind-bundle
symfony console tailwind:init
symfony console tailwind:build --watch # during development
The bundle downloads the standalone Tailwind binary — no npm, no node_modules — and
registers an AssetMapper compiler, so assets/styles/app.css is processed on the fly in
dev and at asset-map:compile time in production. Pin binary_version in
config/packages/symfonycasts_tailwind.yaml: unpinned, a Tailwind major arrives on
whichever machine builds next. The form theme is declared once, globally:
# config/packages/twig.yaml
twig:
form_themes: ['tailwind_2_layout.html.twig']
Never {% form_theme form '…' %} in a template. Per-template themes are how a project
ends up with three form styles and no way to change all of them at once.
Symfony UX packages: add when the need appears
| Package |
The need it answers |
symfony/ux-icons |
{{ ux_icon('tabler:book') }} — any icon, inlined SVG, no icon font |
symfony/ux-autocomplete |
Essential the moment an EntityType passes a few dozen options: a <select> with 5 000 <option>s is a slow page and an unusable one |
symfony/ux-chartjs |
Chart.js from a PHP-built dataset |
symfony/ux-toggle-password |
The show/hide eye on a password field |
symfony/ux-lazy-image |
Blurhash placeholder plus native lazy loading |
Install none of them pre-emptively — each adds a bundle, a Stimulus controller and a
line in importmap.php. Production note for ux-icons: on-demand Iconify downloads are
on by default, so a missing icon is fetched from a third-party API at render time.
Run ux:icons:lock to import every icon used into assets/icons/, and disable
on-demand outside dev.
Twig conventions
- snake_case template names and directories:
templates/book/show.html.twig.
- Fragments prefixed
_: _book_row.html.twig. The prefix says "not a page, not
routable, included by something".
- Three-level inheritance:
base.html.twig (document, importmap(), meta) →
layout/*.html.twig (a section's chrome) → the page. Pages extending base directly
duplicate chrome; four levels become unfollowable.
- Translation keys name intent, not text:
book.delete.confirm, not
Are you sure?. Format XLIFF, one file per domain and locale — a key that is
the English sentence changes identity the day the wording changes.
asset() and path(), always. A hardcoded /assets/app.js bypasses the digest
and is served stale forever; a hardcoded /books/12 breaks when the route moves.
- Twig filters and functions via
Twig\Attribute\AsTwigFilter / AsTwigFunction /
AsTwigTest — namespace Twig\, from twig/twig itself, not a Symfony one — rather
than an AbstractExtension class. Twig 3.12+; below that, write the extension class.
When the front end misbehaves
| Symptom |
Cause |
404 on /assets/… in production |
asset-map:compile was not run at deploy |
| Assets load but every request boots PHP |
Same cause: asset_mapper.server is covering for it |
| Slower than the old bundle |
HTTP/1.1, or no compression. Check the protocol before touching code |
| JS works, then stops after clicking a link |
An inline <script> or a DOMContentLoaded listener; Turbo Drive swapped the body |
| A Stimulus controller never connects |
File must be name_controller.js under assets/controllers/, element data-controller="name" |
| CSS changes do not appear |
No tailwind:build --watch running, or a stale public/assets/ from a previous compile |
| Full page reload on every Turbo navigation |
Something in <head> differs per request |
importmap:audit fails the build |
Correct: a pinned JS package has an advisory. importmap:update it |
| A Live Component "loses" a value between clicks |
The property is not a LiveProp, so it is not part of the serialised state |
| A list page issues hundreds of queries |
An entity reached a template. Pass a DTO |
| A stream response is downloaded instead of applied |
The response went out as text/html — $request->setRequestFormat(TurboBundle::STREAM_FORMAT) was not called |
| A stream response arrives but nothing moves |
targets takes a CSS selector, so a bare id matches nothing. targets="#book_list", or target="book_list" |
Reference files
| File |
When to read it |
references/assetmapper.md |
Adding a dependency, CSS and Tailwind, CSP, preloading, debugging a missing or stale asset, the production checklist |
references/components.md |
Writing a Twig or Live Component, props and DTOs, forms in a Live Component, testing components |
references/turbo-and-stimulus.md |
Turbo Drive/Frames/Streams, Mercure, the page lifecycle, writing and debugging a Stimulus controller |
1---2name: symfony-yoandev-frontend3description: Build and debug the front end of a Symfony application with AssetMapper, Twig, Stimulus, Turbo and Symfony UX — no bundler, no Node build step. Use this skill whenever someone asks to make a page interactive, add a filter or a live search, add an autocomplete or a dropdown, add a JavaScript library, style a page or set up Tailwind, add an icon, build a reusable Twig component or a Live Component, add Turbo Drive, Frames or Streams, write a Stimulus controller, organise templates or translation keys, or set a form theme. Also use it for the failures: "my JavaScript does not run", "my JS stopped working after clicking a link", "the CSS did not update", "the page is slow to load", "the assets 404 in production", "the images are served by PHP", "importmap:audit is failing" — and whenever the question is *where* a piece of behaviour belongs, between a Stimulus controller, a Live Component and a Twig Component.4---56# Frontend78> **Tier: core the moment there is a front end** — AssetMapper, Stimulus and the no-bundler rule. Live Components, Mercure and every UX package are on demand — add them when the need appears, as the tables below say.910Twig, Stimulus, Turbo and Symfony UX, served by AssetMapper. No bundler.1112```bash13symfony console importmap:require chart.js # add a JS dependency14symfony console debug:asset-map # what is actually mapped15symfony console importmap:audit # vulnerable JS dependencies16symfony console asset-map:compile # deploy step — see below17```1819## The price of no bundler, stated once2021AssetMapper serves your files as ES modules with an import map. There is no22compilation step, so there is **no JSX, no Vue or Svelte single-file components, no23TypeScript, no advanced transpilation**, and no tree-shaking of a dependency you only24use a corner of.2526That is the deal, and it is not renegotiated per feature. If a request genuinely needs27a JavaScript framework with a build step, say it is out of scope rather than quietly28introducing a bundler — a bundler is a second toolchain, a second lockfile, a second CI29job and a second thing that breaks on a Node upgrade. (For orientation on an existing30project only: **Webpack Encore is now Symfony's legacy option** and **`symfony/reprise`**31is the official Vite/Rsbuild integration. Neither belongs in a project already on32AssetMapper.)3334## The production trap3536This is the part people get wrong, and it is why "AssetMapper is slow" is a widespread37belief. **AssetMapper concatenates nothing** — a page pulls dozens of small files. That38is fine, better than a bundle for cache granularity, under two conditions:3940- **HTTP/2 or HTTP/3.** Over HTTP/1.1 the browser opens six connections and queues the41 rest; many small files then lose to one bundle, decisively. HTTP/2 is a42 **prerequisite, not an optimisation**. `symfony server:start` gives it locally,43 Caddy/FrankenPHP gives it in production; a naked HTTP/1.1 nginx does not.44- **Compression.** Each file is small; the wins come from brotli/zstd/gzip. Since45 Symfony 7.3, `framework.asset_mapper.precompress: true` writes `.br` / `.zst` / `.gz`46 next to each compiled asset for the web server to serve directly. On 6.4–7.2 there is47 no such option — compress at the web-server level. The rule does not change, only the48 means.4950And the deploy step that decides whether any of this matters:5152```bash53php bin/console asset-map:compile54```5556Without it, `framework.asset_mapper.server` picks up the slack and serves every asset57**through the PHP process** — a full kernel boot per image, per stylesheet, per module.58The site works, which is exactly the problem: nobody notices until the traffic does.59(The rest of the deploy sequence belongs to `symfony-yoandev-deployment`.)6061## Adding a JavaScript dependency6263```bash64symfony console importmap:require chart.js # pin it in importmap.php65symfony console importmap:install # restore assets/vendor/ from it66symfony console importmap:outdated # what has moved67symfony console importmap:audit # known vulnerabilities68```6970`importmap:audit` **belongs in CI**, next to `composer audit`. With no npm anywhere in71the loop, nothing else will ever report that a pinned JavaScript package has a72published advisory. It is the most-forgotten check on this stack.7374## Where behaviour lives: follow the state7576One question decides it, every time: **where does the state live?**7778| State lives… | Use | Because |79|---|---|---|80| In the browser only | **Stimulus controller** | Nothing to ask the server: a dropdown open/closed, copy-to-clipboard, a character counter, drag-reorder before saving |81| On the server, **remembered from one interaction to the next** | **Live Component** | The server owns it and must re-render *with* it: a filtered book list, a search, a form validated as you type, a paginated table |82| On the server, with **nothing to remember** | **A controller returning a Turbo Stream** | One submission, several zones to refresh, no value carried between round trips — see the arbitration below |83| Nowhere — it is rendering | **Twig Component** | A function from props to HTML: a badge, a book card, a rating display, a modal shell |8485The failure modes this prevents are specific: a Stimulus controller that filters in86JavaScript duplicates a rule that already lives in a repository, and the two drift; a87Live Component used for a visual toggle sends an HTTP request to open a menu; a Twig88Component given mutable state grows a re-render mechanism nobody asked for.8990## Live Component, Stream response, pushed Stream9192Three tools update part of a page from the server. **One question arbitrates, and it is93answerable without taste:**9495> **Is there state to remember between two interactions?**96>97> - **Yes** → **Live Component.** The state *is* the component.98> - **No, and the user acted** → **a Turbo Stream returned as the response to that99> submission.**100> - **No, and the user did not act** → **a Turbo Stream pushed** over Mercure.101102| The interaction | Tool | Why that one |103|---|---|---|104| A filter that remembers what it is filtering on, a search refining as you type, a form validated field by field, a multi-step wizard | **Live Component** | The value must survive the round trip *and come back rendered*. That is exactly what a serialised `LiveProp` buys |105| A form POST after which several disjoint zones must change — the list gains a row, the counter goes up, a flash appears | **Turbo Stream response** (`text/vnd.turbo-stream.html`) | Nothing to remember: one submission, one response, several targets. No component class, no state in the DOM, one round trip |106| A notification, a change made by another user, progress on an async task | **Turbo Stream pushed** over Mercure | Nobody clicked. There is no request to answer, so the server has to open one |107108The reasoning behind the first row: a Live Component round trip returns *the component's109own rendered state* — no target ids to name, no fragment template to keep in step with110the full-page render, props preserved between interactions. That machinery costs a111component class, a lifecycle to understand and an HTTP request per interaction, and it112only pays when there is state for it to carry. A component whose props are identical on113every render is a controller with extra steps.114115The reasoning behind the second: answering a form submission with a stream is Turbo's own116documented primary usage, and it is stateless by construction. You name the targets and117you write the fragments — that is the price — but there is nothing to serialise into the118DOM, nothing to rehydrate and nothing to remount. Reach for it as the **default** as soon119as one submission must touch two zones that a single Drive navigation or a single frame120cannot cover together. The controller side is a request format and a template:121`references/turbo-and-stimulus.md` has the worked example.122123The third row is the unchanged one, and the expensive one: **a Mercure hub is another124process to run, monitor and secure**, with its own JWT configuration and reachable from125the browser. "Add a live notification" is an infrastructure decision, not a template126change. Say so before proposing it.127128Two warnings survive the arbitration, because they are about the tools rather than about129the choice between them:130131- **A Live Component remounts on every interaction.** Constructor, `mount()`, prop132 rehydration, full render — per keystroke. An expensive `LiveProp`, or a render path133 that fans out into a dozen queries, does not make the page slow, it makes it unusable.134- **A `#[LiveAction]` is a publicly postable endpoint.** The route is135 `ux_live_component` and anyone can post to it; the fact that its button only renders136 for admins protects nothing. It needs `#[IsGranted]` exactly as a controller action137 does.138139## Components take DTOs, never entities140141```twig142<twig:BookCard :book="book" /> {# book is a BookCardView, not a Book entity #}143```144145A Twig template holding a Doctrine entity triggers lazy loading from inside rendering:146`book.author.name` in a loop is an N+1 nobody sees in the code, and147`book.ratings|length` hydrates every rating row to count them. Those queries happen in a148template, far from any repository, invisible to a service-level test. Pass a DTO — a149read model built by the service, with exactly the fields the component renders. For a150Live Component it is doubly binding: a `LiveProp` is serialised into the DOM and151rehydrated on every interaction, so an entity there means an ORM round-trip per152keystroke.153154**The Live Component trap:** every interaction is an HTTP request and the component is155re-instantiated — `mount()` runs again, props rehydrate, dependencies are re-injected,156anything held in a plain property is gone. Treat "how many queries does one keystroke157cost" as a real question. Details: `references/components.md`.158159## Turbo160161`ux-turbo` is three things with three different scopes:162163- **Drive** — intercepts links and forms, fetches the page, swaps the `<body>`. On by164 default once installed.165- **Frames** — `<turbo-frame id="…">`; a link inside it replaces only it.166- **Streams** — fragments (`append`, `replace`, `remove`, …) applied to targets. They167 reach the page two ways: as the **response to a form submission** (Turbo asks for168 `text/vnd.turbo-stream.html` on every non-GET submission, so the controller only has169 to answer in that format), or **pushed** over Mercure with nobody interacting.170171**Drive changes the page lifecycle, and that is what it breaks.** `DOMContentLoaded`172fires once, on the first load, and never again — any script assuming it runs per page173stops working after the first navigation. Turbo also compares `<head>` between174navigations, so anything in there that changes per request (a nonce, a timestamp) forces175a full reload. The fix is not a workaround, it is the rule below: behaviour lives in a176Stimulus controller, whose `connect()` runs on every insertion, Turbo swaps included.177See `references/turbo-and-stimulus.md`.178179## Stimulus180181- **One controller per behaviour**, named after what it does, not where it sits:182 `clipboard_controller.js`, `character_counter_controller.js` — not183 `book_page_controller.js`.184- **No inline `<script>` in a template.** It runs once, does not survive a Turbo185 navigation, cannot be reused, and is invisible to `importmap:audit`. The one186 exception is a `<script type="application/json">` data island read by a controller.187- **No business rule in JavaScript.** A threshold, a price, an eligibility check188 decided client-side *will* diverge from the server's — and the server's is the one189 that counts. JavaScript may display a rule; it may not own one.190191## Tailwind, without Node192193```bash194composer require symfonycasts/tailwind-bundle195symfony console tailwind:init196symfony console tailwind:build --watch # during development197```198199The bundle downloads the standalone Tailwind binary — no `npm`, no `node_modules` — and200registers an AssetMapper compiler, so `assets/styles/app.css` is processed on the fly in201dev and at `asset-map:compile` time in production. Pin `binary_version` in202`config/packages/symfonycasts_tailwind.yaml`: unpinned, a Tailwind major arrives on203whichever machine builds next. The form theme is declared **once**, globally:204205```yaml206# config/packages/twig.yaml207twig:208 form_themes: ['tailwind_2_layout.html.twig']209```210211Never `{% form_theme form '…' %}` in a template. Per-template themes are how a project212ends up with three form styles and no way to change all of them at once.213214## Symfony UX packages: add when the need appears215216| Package | The need it answers |217|---|---|218| `symfony/ux-icons` | `{{ ux_icon('tabler:book') }}` — any icon, inlined SVG, no icon font |219| `symfony/ux-autocomplete` | Essential the moment an `EntityType` passes a few dozen options: a `<select>` with 5 000 `<option>`s is a slow page and an unusable one |220| `symfony/ux-chartjs` | Chart.js from a PHP-built dataset |221| `symfony/ux-toggle-password` | The show/hide eye on a password field |222| `symfony/ux-lazy-image` | Blurhash placeholder plus native lazy loading |223224Install none of them pre-emptively — each adds a bundle, a Stimulus controller and a225line in `importmap.php`. Production note for `ux-icons`: on-demand Iconify downloads are226**on by default**, so a missing icon is fetched from a third-party API at render time.227Run `ux:icons:lock` to import every icon used into `assets/icons/`, and disable228on-demand outside dev.229230## Twig conventions231232- **snake_case** template names and directories: `templates/book/show.html.twig`.233- **Fragments prefixed `_`**: `_book_row.html.twig`. The prefix says "not a page, not234 routable, included by something".235- **Three-level inheritance**: `base.html.twig` (document, `importmap()`, meta) →236 `layout/*.html.twig` (a section's chrome) → the page. Pages extending `base` directly237 duplicate chrome; four levels become unfollowable.238- **Translation keys name intent, not text**: `book.delete.confirm`, not239 `Are you sure?`. Format **XLIFF**, one file per domain and locale — a key that *is*240 the English sentence changes identity the day the wording changes.241- **`asset()` and `path()`, always.** A hardcoded `/assets/app.js` bypasses the digest242 and is served stale forever; a hardcoded `/books/12` breaks when the route moves.243- Twig filters and functions via **`Twig\Attribute\AsTwigFilter`** / `AsTwigFunction` /244 `AsTwigTest` — namespace `Twig\`, from `twig/twig` itself, not a Symfony one — rather245 than an `AbstractExtension` class. Twig 3.12+; below that, write the extension class.246247## When the front end misbehaves248249| Symptom | Cause |250|---|---|251| 404 on `/assets/…` in production | `asset-map:compile` was not run at deploy |252| Assets load but every request boots PHP | Same cause: `asset_mapper.server` is covering for it |253| Slower than the old bundle | HTTP/1.1, or no compression. Check the protocol before touching code |254| JS works, then stops after clicking a link | An inline `<script>` or a `DOMContentLoaded` listener; Turbo Drive swapped the body |255| A Stimulus controller never connects | File must be `name_controller.js` under `assets/controllers/`, element `data-controller="name"` |256| CSS changes do not appear | No `tailwind:build --watch` running, or a stale `public/assets/` from a previous compile |257| Full page reload on every Turbo navigation | Something in `<head>` differs per request |258| `importmap:audit` fails the build | Correct: a pinned JS package has an advisory. `importmap:update` it |259| A Live Component "loses" a value between clicks | The property is not a `LiveProp`, so it is not part of the serialised state |260| A list page issues hundreds of queries | An entity reached a template. Pass a DTO |261| A stream response is downloaded instead of applied | The response went out as `text/html` — `$request->setRequestFormat(TurboBundle::STREAM_FORMAT)` was not called |262| A stream response arrives but nothing moves | `targets` takes a **CSS selector**, so a bare id matches nothing. `targets="#book_list"`, or `target="book_list"` |263264## Reference files265266| File | When to read it |267|---|---|268| `references/assetmapper.md` | Adding a dependency, CSS and Tailwind, CSP, preloading, debugging a missing or stale asset, the production checklist |269| `references/components.md` | Writing a Twig or Live Component, props and DTOs, forms in a Live Component, testing components |270| `references/turbo-and-stimulus.md` | Turbo Drive/Frames/Streams, Mercure, the page lifecycle, writing and debugging a Stimulus controller |