Twig component, Live Component, and template conventions for Symfony bundles
php artisan boost:add-skill symkit/menu-bundle
Save this content to: .cursor/rules/twig-bundle.mdc
# Twig & Live Component Conventions
## Twig Helpers (Mandatory)
Always use Twig helpers — never manual `data-*` attributes (XSS protection, refactor-safe):
```twig
{{ stimulus_controller('vendor--bundle--name', { url: path('route') }) }}
{{ stimulus_target('vendor--bundle--name', 'input') }}
{{ stimulus_action('vendor--bundle--name', 'actionName', 'input') }}
```
## Internationalization (i18n)
**Every user-facing string must be translated** — no hardcoded text anywhere.
- Use `{% trans_default_domain 'VendorNameBundle' %}` at the top of base templates to avoid repeating the domain
- Child templates inherit the domain from `{% extends %}`
- XLIFF files in `translations/` at bundle root (auto-discovered)
- Translation keys: `error.404.heading`, `error.action.home` — dot-separated, descriptive
- Always provide at least 2 locales (e.g. `fr` + `en`)
- Dynamic locale: `lang="{{ app.request.locale|default('en') }}"` on `<html>`
- **Extract all strings**: Twig (`{% trans %}`, `|trans`), form labels/help/placeholders, constraint messages, controller flash messages, email subjects/bodies, error pages
- 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
## Self-Contained Pages (Error Pages, Emails)
For pages that must render independently of the application's asset pipeline:
- **Inline CSS only** — never CDN (`cdn.tailwindcss.com`, `unpkg.com`, etc.)
- **Inline SVG** for icons — never external icon libraries via `<script>`
- **No bare `<script src="">`** — pages must work offline / behind strict CSP
- CSS custom properties (`--var`) for theme values overridable by child templates
- Use semantic CSS class names (`.terminal-header`, not `.bg-subtle .px-4 .py-3`)
## Security in Templates
- **Never expose** `app.request.clientIp` (information leak)
- **Never display** fake trace IDs with `random()` (misleading)
- `app.request.pathInfo` is auto-escaped by Twig but still reveals internal paths — use with caution
## PHP → JS Data Transfer
Symfony Serializer with groups. Never `json_encode()` on objects:
```php
$data = $this->serializer->normalize($entity, 'json', ['groups' => 'entity:read']);
```
## Twig Component vs Live Component
- No reactivity needed → Twig Component (`final class`, registered via `twig_component.defaults` in `prependExtension()`)
- Server-side reactivity → Live Component (`final class` + `DefaultActionTrait`, registered via `twig_component.defaults` in `prependExtension()`)
- **No `#[AsTwigComponent]` / `#[AsLiveComponent]` attributes in bundle code** — registration via config only (see symfony-bundle rule)
## Component Rules
- `{{ attributes }}` mandatory on root element of every component
- Prefix CSS classes with bundle name (`acme-blog-`)
- `{% props %}` for UI-only components (no PHP class)
- Namespace: `@VendorName`, support host app override
## Live Component Security
```
#[LiveProp] → read-only (server-controlled)
#[LiveProp(writable: true)] → scalars ONLY
#[LiveProp(writable: ['field1'])] → entities with granular fields
NEVER #[LiveProp(writable: true)] on entire entity (ID manipulation attack)
```
## Live Component Patterns
- `debounce(300)` or `lazy` on text inputs
- Stable `id` mandatory on iterated elements in `{% for %}` loops (DOM morphing)
- `LiveCollectionType` for dynamic forms (no JS prototype manipulation)
- Communication: `$this->emit('eventName', [...])` + `#[LiveListener('eventName')]`
- No business logic in components — delegate to services
## FORBIDDEN
- CDN links in self-contained templates (error pages, emails)
- Twig macros when a Twig Component does the same (components are typed, injectable)
- `ux-lazy-image` on LCP image (SEO penalty)
- `json_encode()` for objects in templates
- Exposing `app.request.clientIp` in user-facing templates
Always use Twig helpers — never manual data-* attributes (XSS protection, refactor-safe):
{{ stimulus_controller('vendor--bundle--name', { url: path('route') }) }}
{{ stimulus_target('vendor--bundle--name', 'input') }}
{{ stimulus_action('vendor--bundle--name', 'actionName', 'input') }}
Every user-facing string must be translated — no hardcoded text anywhere.
{% trans_default_domain 'VendorNameBundle' %} at the top of base templates to avoid repeating the domain{% extends %}translations/ at bundle root (auto-discovered)error.404.heading, error.action.home — dot-separated, descriptivefr + en)lang="{{ app.request.locale|default('en') }}" on <html>{% trans %}, |trans), form labels/help/placeholders, constraint messages, controller flash messages, email subjects/bodies, error pages'label' => 'form.field_name', never rely on auto-generated labels'message' => 'validation.field.constraint')translation:extract regularly to catch missing keysFor pages that must render independently of the application's asset pipeline:
cdn.tailwindcss.com, unpkg.com, etc.)<script><script src=""> — pages must work offline / behind strict CSP--var) for theme values overridable by child templates.terminal-header, not .bg-subtle .px-4 .py-3)app.request.clientIp (information leak)random() (misleading)app.request.pathInfo is auto-escaped by Twig but still reveals internal paths — use with cautionSymfony Serializer with groups. Never json_encode() on objects:
$data = $this->serializer->normalize($entity, 'json', ['groups' => 'entity:read']);
final class, registered via twig_component.defaults in prependExtension())final class + DefaultActionTrait, registered via twig_component.defaults in prependExtension())#[AsTwigComponent] / #[AsLiveComponent] attributes in bundle code — registration via config only (see symfony-bundle rule){{ attributes }} mandatory on root element of every componentacme-blog-){% props %} for UI-only components (no PHP class)@VendorName, support host app override#[LiveProp] → read-only (server-controlled)
#[LiveProp(writable: true)] → scalars ONLY
#[LiveProp(writable: ['field1'])] → entities with granular fields
NEVER #[LiveProp(writable: true)] on entire entity (ID manipulation attack)
debounce(300) or lazy on text inputsid mandatory on iterated elements in {% for %} loops (DOM morphing)LiveCollectionType for dynamic forms (no JS prototype manipulation)$this->emit('eventName', [...]) + #[LiveListener('eventName')]ux-lazy-image on LCP image (SEO penalty)json_encode() for objects in templatesapp.request.clientIp in user-facing templatesHow can I help you explore Laravel packages today?