07/04/2019

Rendering a Twig template outside a controller

The short version

  • A template file

    Inject Twig\Environment and call render(). No controller, no container, nothing special.

  • A template from the database

    createTemplate() compiles a string. Twig_Loader_String was removed in Twig 2 and is not coming back.

  • The catch

    String templates compile on every call, and a template a user wrote is code a user wrote. Sandbox it.

Rendering Twig outside a controller — in a service that builds an email body, say — needs nothing special. Ask for the Environment and use it:

use Twig\Environment;

class EmailBuilder
{
    public function __construct(private Environment $twig)
    {
    }

    public function header(): string
    {
        return $this->twig->render('email/header.html.twig');
    }

    public function footer(string $site): string
    {
        return $this->twig->render('email/footer.html.twig', [
            'site' => str_ireplace('www.', '', $site),
        ]);
    }
}

That is the whole thing. Autowiring gives you the same Environment the controller uses, templates/ is already on its loader, and the return value is a string you can put in an email, a queue message or a JSON response.

When the template itself comes from the database

The interesting case, and the one the old version of this article was really about: the template is not a file at all. It is a string a user edited, stored in a row.

$template = $this->twig->createTemplate($rowFromDatabase);

return $template->render(['site' => $site]);

createTemplate() is the supported way to do that. Do not reach for Twig_Loader_String — it was deprecated in Twig 1.18 with the blunt note that "it should never be used", and removed in 2.0. Twig's own deprecation page names createTemplate() as the replacement.

Two things to know before you put it in a loop:

  • It compiles on every call. A file template is compiled once and cached; a string template is parsed and compiled each time unless you cache the result yourself. Fine for one email, not fine inside a thousand-row export.
  • Anything a user can edit is a template they can run. Twig is a programming language. If the string comes from somebody who is not you, render it through Twig's sandbox rather than the ordinary environment.

Why the 2019 version does not run

$TwigContainer = new \Twig_Environment(new \Twig_Loader_String());
$path = $this->container->getParameter('kernel.root_dir').'/../Resources/views/email-header.html.twig';
$emailHeader = @$TwigContainer->render(file_get_contents($path));

Four separate things, and they fail at different points.

\Twig_Loader_String does not exist. Deprecated in Twig 1.18, removed in 2.0. Nothing in support has it. The underscore class names went at the same time — \Twig_Environment is Twig\Environment now.

kernel.root_dir does not exist either. Deprecated in Symfony 4.2 and removed; kernel.project_dir replaced it, and points at the project root rather than at app/, so the /../ in that path would be wrong even if the parameter resolved.

$this->container in a repository. Repositories stopped being container-aware a long time ago. Whatever you need, ask for it in the constructor.

The @ before each render. It suppresses the exception you most want to see — a missing variable, a syntax error in the template, a file that is not there. The old code would have returned an empty string for all three, silently, and the email would have gone out with a hole in it.

And a word on the repository

The original hedged about whether rendering belongs in a repository at all, and it was right to. A repository's job is fetching rows. If the template is a row, fetch the row there and render it somewhere else — the service that wanted the email is the natural place, and it keeps the repository something you can test without a template engine.

Questions this keeps raising

What replaced Twig_Loader_String?

Environment::createTemplate(), which takes the template source as a string and hands back a template object to render. The String loader was deprecated in Twig 1.18 and removed in 2.0, and Twig's own deprecation page names createTemplate as the replacement.

Do I need to inject the container to reach Twig in a service?

No, and you should not. Type-hint Twig\Environment in the constructor and autowiring supplies the same instance the controllers use. Injecting the container to fetch one service is what service autowiring exists to stop.

Is it safe to render a template a user wrote?

Not through the ordinary environment. Twig is a programming language, so a template is code. If the source is user-supplied, use Twig's sandbox extension, which allows only the tags, filters and methods you list. Twig 4 tightened the sandbox further, including checking tests and removing the silent-failure paths.

Why is my string template slow in a loop?

createTemplate compiles on every call. File templates are compiled once and cached on disk; a string has no cache key unless you give it one. Render it once outside the loop where you can, or cache the compiled result yourself.