A problem with Twig

I’m a fan of twig, and wouldn’t consider moving back to plain php. But, it does come with a few problems. In this post we’ll explore one of the problems i have with twig, and how to work around it.

The problem

When you use a twig file, you do not know what variables it needs, what variables i can use, and what types those variables should be. You have to read the template, or execute it and see what errors you get.

This isn’t bad if you’ve worked on a project for a long time. But when you work on a project that isn’t yours, it can be difficult to understand what a template needs. Is that variable passed to the template, or is it a global? What type is it supposed to be? We’ve had issues multiple times where a variable wasn’t passed to a template, and then the site went down.

You can check this by having e2e tests that check all possible paths, and verify your twig templates. But most code bases won’t have those, or the tests take too long to be of value. What if we could be sure what our templates need, and make sure it gets exactly that?

A solution

At first i came up with the following solution. A service per template that would require the correct variables to be passed. This way, all you need to do is find the correct service for the template, and you know what variables it needs. The problem here is that you will need a service for every single template. This may be okay for small apps, but for bigger apps this seems like a bad solution.

<?php
class AccountPageAction
{
    public function __construct(
        private AccountPageView $view,
        private Security $security
    ) {}

    public function __invoke(): Response
    {
        // Deal with things
        return new Response(
            $this->view->render($this->security->getUser())
        );
    }
}

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

    public function render(User $user) {
        return $this->twig->render('account/view.html.twig', [
            'user' => $user,
        ]);
    }
}

I posted about my ideas on twitter and got some responses. The response from @azjezz seemed like the perfect solution.

One thing that i liked about this approach is also the Renderer class that was used. He gave the example of a Responder class that he used later in that thread.

So after a few changes i ended up with the following Responder and template:

<?php
final class Responder
{
    /**
     * Render the given twig template and return an HTML response.
     *
     * @param array<string, string|list<string>> $headers
     *
     * @throws TwigError
     */
    public function render(Template $template, int $status = 200, array $headers = []): Response
    {
        $content = $this->twig->render($template->getTemplate(), $template->getContext());
        $response = new Response($content, $status, $headers);
        if (!$response->headers->has('Content-Type')) {
            $response->headers->set('Content-Type', 'text/html; charset=UTF-8');
        }

        return $response;
    }
    // The rest of the class
}

interface Template
{
    public function getTemplate(): string;
    /** @return array<mixed> */
    public function getContext(): array;
}

Now, even when the template is used in multiple end points, its clear what the template needs. Passing in the wrong type will alert a tool like PHPStan. The same goes for forgetting to pass in a certain variable. We can even use a tool like deptrac to make sure all our controllers use the responder, instead of rendering twig templates directly.

If your template has a lot of optional variables, you can consider using named arguments. This will save you from having to pass in a lot of null or other default values.

Using this, the original AccountPageAction now looks like this.

<?php
class AccountPageAction
{
    public function __construct(
        private Responder $responder
    ) {}

    public function __invoke(): Response
    {
        // Deal with things
        return $this->responder->render(
            new AccountPageTemplate($this->security->getUser())
        );
    }
}

class AccountPageTemplate implements Template
{
    public function __construct(private User $user) {}

    public function getTemplate(): string
    {
        return 'account/view.html.twig';
    }

    /** @return array<mixed> */
    public function getContext(): array
    {
        return ['user' => $this->user];
    }
}

By using this Template interface and the Responder we now always know what variables our templates need. We will know if we forgot a variable. It can save you a broken website due to a missing variable, and should make your twig a bit safer.

Avatar
Gert de Pagter
Software Engineer

My interests include software development, math and magic.