Escaping and Assets

Twig escapes output by default, chooses the strategy from the filename, and serves CSS and JS through AssetMapper.

Syntax{{ value|e('html'|'js'|'css'|'url'|'html_attr') }}

Every {{ ... }} is escaped before it is printed. If name holds <script>alert(1)</script>, the page shows that text instead of running it. This is auto-escaping, and it is on by default. It is the main reason a Twig page is safe to render with user input in it.

The strategy comes from the filename

The escaping strategy is chosen when the template is compiled, from its name. A *.html.twig file escapes for HTML; a *.js.twig file escapes for JavaScript; a *.css.twig file escapes for CSS. A *.txt.twig file is the one that escapes nothing at all, and every name Twig does not recognise - including a name with no format extension, such as report.twig - falls back to HTML escaping. Keep the format extension on every template anyway, because it is the only thing that says which strategy you meant.

raw, and when it is honest

{{ value|raw }} prints the value unescaped. It is safe only for markup your own code produced, or for a value already escaped for the position it lands in. It is never safe for anything a user typed, however trusted the user, and that includes rich text from an editor unless a sanitiser has already cleaned it.

Other contexts

HTML escaping is wrong inside a <script>. Use {{ value|e('js') }} there; the other strategies are html, css, url and html_attr. |e is an alias of |escape, so {{ v|e('js') }} and {{ v|escape('js') }} are identical. Better still, pass structured data in a data- attribute as JSON and read it with JSON.parse(); attribute escaping is exactly what Twig already does there.

Assets

AssetMapper serves files from assets/ without a build step. {{ asset('styles/app.css') }} returns a versioned URL, and {{ importmap('app') }} prints the import map, the module script that loads your entry point, and preload links.

Example

Example · php
// src/Controller/DashboardController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class DashboardController extends AbstractController
{
    // GET /dashboard?q=<b>hi</b> returns 200. The page shows the literal
    // text <b>hi</b>, because Twig escapes it on the way out.
    #[Route('/dashboard', name: 'app_dashboard', methods: ['GET'])]
    public function index(Request $request): Response
    {
        // ->query is the query string. ->request would be the POST body.
        $search = (string) $request->query->get('q', '');

        // Hand structured data to JavaScript as JSON. It is printed into a
        // normal HTML attribute, so Twig's own escaping is the right one
        // and no |raw is needed in the template.
        $config = json_encode(
            ['refreshSeconds' => 30, 'label' => 'Today & tomorrow'],
            JSON_THROW_ON_ERROR,
        );

        return $this->render('dashboard/index.html.twig', [
            'search' => $search,
            'config' => $config,
        ]);
    }
}

When to use it

  • Render a search term from the query string without opening an XSS hole.
  • Pass a configuration object from a controller to a JavaScript module on the page.
  • Add a CSS file and an npm package to a page without setting up a build step.

More examples

Escaped output, a data attribute, and one justified raw

These four lines cover almost every case you will meet: plain text, data for JavaScript, a value inside a script tag, and trusted markup. The data attribute is the pattern to prefer, because it needs no special filter and keeps the JSON out of the script parser.

Example · twig
{# templates/dashboard/index.html.twig #}
{% extends 'base.html.twig' %}

{% block content %}
    {# Escaped for HTML: tags typed by the user arrive as text. #}
    <p>Results for {{ search }}</p>

    {# The JSON string is escaped as an attribute value, which is correct
       for this position. JavaScript reads it with JSON.parse(). #}
    <div id="dashboard" data-config="{{ config }}"></div>

    {# Inside a script, HTML escaping is the wrong strategy. #}
    <script>
        const query = "{{ search|e('js') }}";
    </script>

    {# |raw only because this markup is built by the application itself and
       contains no user input. #}
    <footer>{{ legal_notice_html|raw }}</footer>
{% endblock %}

CSS and JavaScript in the base template

asset() turns a logical path inside assets/ into a versioned public URL, so a changed file gets a new name and caches do not serve the old one. Keeping importmap() in its own block lets a single page replace the JavaScript entry point without touching the rest of the head.

Example · twig
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Acme{% endblock %}</title>

        {% block stylesheets %}
            {# assets/styles/app.css, served with a version hash in the name #}
            <link rel="stylesheet" href="{{ asset('styles/app.css') }}">
        {% endblock %}

        {% block javascripts %}
            {# Prints a <script type="importmap">, a module script that
               imports 'app', and modulepreload links. #}
            {% block importmap %}{{ importmap('app') }}{% endblock %}
        {% endblock %}
    </head>
    <body>
        {% block content %}{% endblock %}
    </body>
</html>

The import map configuration

This file is the list of JavaScript names the browser is allowed to import. After adding an entry you write import { Alert } from 'bootstrap' in assets/app.js, with no bundler involved. Install it all with composer require symfony/asset-mapper symfony/asset symfony/twig-pack.

Example · php
// importmap.php
declare(strict_types=1);

return [
    'app' => [
        'path' => './assets/app.js',
        'entrypoint' => true,
    ],

    // Added by: php bin/console importmap:require bootstrap
    'bootstrap' => [
        'version' => '5.3.0',
    ],
];

Discussion

  • Be the first to comment on this lesson.