Автоекранування в Symfony увімкнене з коробки: twig.autoescape за замовчуванням має значення name. Спрацьовує воно на етапі компіляції шаблону, коли кожен {{ ... }} Twig перетворює на виклик escaper'а з конкретною стратегією. Саму стратегію дає FileExtensionEscapingStrategy, дивлячись на ім'я файлу: .html.twig дає html, .js.twig дає js, .css.twig дає css, а .txt.twig не дає нічого, там екранування вимкнене. Під html ховається htmlspecialchars з ENT_QUOTES | ENT_SUBSTITUTE і кодуванням UTF-8, тож <, >, &, подвійні й одинарні лапки їдуть у сутності. Тому звичайний {{ user.name }} на HTML-сторінці не потребує від вас нічого додаткового.
На співбесіді валяться на іншому: Twig не знає, де саме стоїть ваш вираз. Він не бачить ні того, що навколо <script>, ні того, що атрибут написали без лапок, ні того, що це href. Стратегія одна на весь файл. Звідси й набір явних фільтрів для нестандартних контекстів. |e('html_attr') екранує все, крім букв і цифр, тому переживає навіть атрибут без лапок; |e('js') перетворює символи на \xHH і \uHHHH для вставки в JS-рядок; |e('css') робить те саме для значень у стилях; |e('url') це rawurlencode для частини URL. Останній регулярно розуміють неправильно: він кодує параметр, а не перевіряє схему посилання, тож javascript:alert(1) у полі «мій сайт» ним не вилікуєш, схему валідують у PHP і зберігають уже перевірене значення. Коли цілий блок шаблону пишеться під інший контекст, є тег {% autoescape 'js' %} ... {% endautoescape %}.
Вимикати екранування доводиться рідко, і привід завжди один: значення вже є HTML, який ми свідомо хочемо показати. Легальні джерела такого HTML: власний рендер Markdown, згенерована в коді розмітка, контент користувача, пропущений через HtmlSanitizer з пакета symfony/html-sanitizer (є й Twig-фільтр sanitize_html, коли чистити зручніше на рівні шаблону). Тут {{ value|raw }} виправданий. А от «на сторінці видно & замість &» приводом не є. Це симптом подвійного екранування: дані приходять уже обробленими з контролера або лежать екранованими в базі. raw у такій ситуації ховає справжню проблему й заразом відкриває XSS на решті полів того самого тексту. {% autoescape false %} на півшаблону - та сама помилка, тільки масштабніша.
Успадкування шаблонів має до безпеки прямішу дотичність, ніж здається. Коли базовий base.html.twig описує каркас, а сторінки перевизначають {% block %} і за потреби дотягують батьківський вміст через {{ parent() }}, розмітка не розмножується копіпастою, а разом із нею не розмножуються й рішення на кшталт raw. Для повторюваних фрагментів із власною структурою зручний {% embed %}: той самий include, але з можливістю перевизначити блоки всередині. А {% include %} тримає пастку - підшаблон має власну стратегію екранування за своїм іменем, і вивід тега через escaper не проходить. Підключений у HTML-сторінку partials/footer.txt.twig вставить дані користувача так, як вони є.
Логіка в шаблоні - окрема тема, яку інтерв'юер зазвичай чіпляє тим самим питанням. Конкатенація рядків, умовне форматування чисел, склейка URL руками: усе це переїжджає у Twig-розширення. Клас на AbstractExtension описує фільтр, а реалізація живе в окремому runtime-класі з RuntimeExtensionInterface, щоб сервіси не піднімалися на кожен рендер. Symfony автоконфігурує обидва теги, тож достатньо створити клас. Критичний там один момент: ['is_safe' => ['html']] в описі фільтра означає, що Twig результат не екрануватиме, і відповідальність за htmlspecialchars переходить на ваш код. Позначити фільтр безпечним і конкатенувати в ньому нікнейм користувача - найтихіший зі способів зламати сторінку, бо в шаблоні при цьому ані raw, ані autoescape false не видно.
{# templates/profile/show.html.twig
Стратегію екранування Twig бере з розширення файлу: .html.twig -> html #}
{% extends 'layout/base.html.twig' %}
{% block title %}Профіль {{ user.name }}{% endblock %} {# екранується автоматично #}
{% block body %}
{{ parent() }} {# спільна розмітка з базового шаблону, а не її копія #}
<h1>{{ user.name }}</h1> {# htmlspecialchars викликається сам #}
{# Атрибут: html_attr закриває і випадок атрибута без лапок #}
<span data-nick="{{ user.nick|e('html_attr') }}">{{ user.nick }}</span>
{# Параметр URL кодуємо окремо; e('url') це rawurlencode, а не перевірка схеми #}
<a href="/articles?author={{ user.nick|e('url') }}">усі статті</a>
{# Чуже посилання: схему http/https перевірено в PHP, тут вистачає звичайного екранування #}
<a href="{{ user.siteUrl }}" rel="nofollow noopener">сайт</a>
{# Дані для JS - через data-атрибут, а не вставкою всередину <script> #}
<div id="chart" data-points="{{ stats.points|json_encode|e('html_attr') }}"></div>
{# raw лише для HTML, який уже пройшов HtmlSanitizer у контролері #}
<div class="bio">{{ user.safeBioHtml|raw }}</div>
{# Форматування живе у Twig-розширенні, а не в шаблоні #}
<p>З нами {{ user.registeredAt|human_since }}</p>
{% endblock %}
Проговоріть вголос, звідки береться стратегія: «Twig екранує за розширенням файлу, а не за місцем виразу в розмітці». Далі одна фраза про `raw` - «тільки для HTML, який зробили ми самі або санітайзер» - і питання зазвичай закрите.