Buy Access to Course
23.

Making a Twig Extension (Filter)

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

When we installed KnpMarkdownBundle, it gave us a new service that we used to parse markdown into HTML. But it did more than that. Open up templates/question/show.html.twig and look down where we print out the answers. Because, that bundle also gave us a service that provided a custom Twig filter. We could suddenly say {{ answer|markdown }} and that would process the answer through the markdown parser:

59 lines | templates/question/show.html.twig
// ... lines 1 - 4
{% block body %}
<div class="container">
// ... lines 7 - 36
<ul class="list-unstyled">
{% for answer in answers %}
<li class="mb-4">
<div class="d-flex justify-content-center">
// ... lines 41 - 43
<div class="mr-3 pt-2">
{{ answer|markdown }}
// ... line 46
</div>
// ... lines 48 - 52
</div>
</li>
{% endfor %}
</ul>
</div>
{% endblock %}

The only problem is that this doesn't use our caching system. We created our own MarkdownHelper service to handle that:

39 lines | src/Service/MarkdownHelper.php
// ... lines 1 - 8
class MarkdownHelper
{
// ... lines 11 - 23
public function parse(string $source): string
{
if (stripos($source, 'cat') !== false) {
$this->logger->info('Meow!');
}
if ($this->isDebug) {
return $this->markdownParser->transformMarkdown($source);
}
return $this->cache->get('markdown_'.md5($source), function() use ($source) {
return $this->markdownParser->transformMarkdown($source);
});
}
}

It uses the markdown parser service but also caches the result. Unfortunately, the markdown filter uses the markdown parser from the bundle directly and skips our cool cache layer.

So. What we really want is to have a filter like this that, when used, calls our MarkdownHelper service to do its work.

make:twig-extension

Let's take this one piece at a time. First: how can we add custom functions or filters to Twig? Adding features to Twig is work... so it should be no surprise that we do this by creating a service. But in order for Twig to understand our service, it needs to look a certain way.

MakerBundle can help us get started. Find your terminal and run:

php bin/console make:

to see our list.

Making the Twig Extension

Let's use make:twig-extension:

php bin/console make:twig-extension

For the name: how about MarkdownExtension. Ding! This created a new src/Twig/MarkdownExtension.php file. Sweet! Let's go open it up:

33 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 8
class MarkdownExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
// If your filter generates SAFE HTML, you should add a third
// parameter: ['is_safe' => ['html']]
// Reference: https://twig.symfony.com/doc/2.x/advanced.html#automatic-escaping
new TwigFilter('filter_name', [$this, 'doSomething']),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('function_name', [$this, 'doSomething']),
];
}
public function doSomething($value)
{
// ...
}
}

Just like with our command, in order to hook into Twig, our class needs to implement a specific interface or extend a specific base class. That helps tell us what methods our class needs to have.

Right now, this adds a new filter called filter_name and a new function called function_name:

33 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 8
class MarkdownExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
// ... lines 14 - 16
new TwigFilter('filter_name', [$this, 'doSomething']),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('function_name', [$this, 'doSomething']),
];
}
// ... lines 27 - 31
}

Creative! If someone used the filter in their template, Twig would actually call the doSomething() method down here and we would return the final value after applying our filter logic:

33 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 8
class MarkdownExtension extends AbstractExtension
{
// ... lines 11 - 27
public function doSomething($value)
{
// ...
}
}

Autoconfigure!

And guess what? Just like with our command, Twig is already aware of our class! To prove that, at your terminal, run:

php bin/console debug:twig

And if we look up... there it is: filter_name. And the reason that Twig instantly sees our new service is not because it lives in a Twig/ directory. It's once again thanks to the autoconfigure feature:

33 lines | config/services.yaml
// ... lines 1 - 8
services:
# default configuration for services in *this* file
_defaults:
// ... line 12
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
// ... lines 14 - 33

Symfony notices that it extends AbstractExtension from Twig:

33 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 4
use Twig\Extension\AbstractExtension;
// ... lines 6 - 8
class MarkdownExtension extends AbstractExtension
{
// ... lines 11 - 31
}

A class that all Twig extensions extend - and thinks:

Oh! This must be a Twig extension! I'll tell Twig about it

Tip

Technically, all Twig extensions must implement an ExtensionInterface and Symfony checks for this interface for autoconfigure. The AbstractExtension class implements this interface.

Adding the parse_markdown Filter

This means that we're ready to work! Let's call the filter parse_markdown... so it doesn't collide with the other filter. When someone uses this filter, I want Twig to call a new parseMarkdown() method that we're going to add to this class:

26 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 8
class MarkdownExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
// ... lines 14 - 16
new TwigFilter('parse_markdown', [$this, 'parseMarkdown']),
];
}
// ... lines 20 - 24
}

Remove getFunctions(): we don't need that.

Below, rename doSomething() to parseMarkdown(). And for now, just return TEST:

26 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 8
class MarkdownExtension extends AbstractExtension
{
// ... lines 11 - 20
public function parseMarkdown($value)
{
return 'TEST';
}
}

Let's do this! In show.html.twig, change to use the new parse_markdown filter:

59 lines | templates/question/show.html.twig
// ... lines 1 - 4
{% block body %}
<div class="container">
// ... lines 7 - 36
<ul class="list-unstyled">
{% for answer in answers %}
<li class="mb-4">
<div class="d-flex justify-content-center">
// ... lines 41 - 43
<div class="mr-3 pt-2">
{{ answer|parse_markdown }}
// ... line 46
</div>
// ... lines 48 - 52
</div>
</li>
{% endfor %}
</ul>
</div>
{% endblock %}

Moment of truth! Spin over to your browser and refresh. Our new filter works!

Of course, TEST isn't a great answer to a question, so let's make the Twig extension use MarkdownHelper. Once again, we find ourselves in a familiar spot: we're inside of a service and we need access to another service. Yep, it's dependency injection to the rescue! Create the public function __construct() with one argument: MarkdownHelper $markdownHelper. I'll hit Alt+Enter and go to "Initialize properties" to create that property and set it below:

34 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 4
use App\Service\MarkdownHelper;
// ... lines 6 - 9
class MarkdownExtension extends AbstractExtension
{
private $markdownHelper;
public function __construct(MarkdownHelper $markdownHelper)
{
$this->markdownHelper = $markdownHelper;
}
// ... lines 18 - 32
}

Inside the method, thanks to our hard work of centralizing our logic into MarkdownHelper, this couldn't be easier: return $this->markdownHelper->parse($value):

34 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 9
class MarkdownExtension extends AbstractExtension
{
// ... lines 12 - 28
public function parseMarkdown($value)
{
return $this->markdownHelper->parse($value);
}
}

$value will be whatever "thing" is being piped into the filter: the answer text in this case.

Ok, it should work! When we refresh... hmm. It's parsing through Markdown but Twig is output escaping it. Twig output escapes everything you print and we fixed this earlier by using the raw filter to tell Twig to not do that.

But there's another solution: we can tell Twig that the parse_markdown filter is "safe" and doesn't need escaping. To do that, add a 3rd argument to TwigFilter: an array with 'is_safe' => ['html']:

34 lines | src/Twig/MarkdownExtension.php
// ... lines 1 - 9
class MarkdownExtension extends AbstractExtension
{
// ... lines 12 - 18
public function getFilters(): array
{
return [
// If your filter generates SAFE HTML, you should add a third
// parameter: ['is_safe' => ['html']]
// Reference: https://twig.symfony.com/doc/2.x/advanced.html#automatic-escaping
new TwigFilter('parse_markdown', [$this, 'parseMarkdown'], ['is_safe' => ['html']]),
];
}
// ... lines 28 - 32
}

That says: it is safe to print this value into HTML without escaping.

Oh, but in a real app, in parseMarkdown(), I would probably first call strip_tags on the $value argument to remove any HTML tags that a bad user may have entered into their answer there. Then we can safely use the final HTML.

Anyways, when we move over and refresh, it's perfect: a custom Twig filter that parses markdown and uses our cache system.

Friends! You rock! Congrats on finishing the Symfony Fundamental course! This was a lot of work and your reward is that everything else you do will make more sense and take less time to implement. Nice job.

In the next course, we're going to really take things up to the next level by adding a database layer so we can dynamically load real questions and real answers. And if you have a real question and want a real answer, we're always here for you down in the comments.

Alright friends - seeya next time!