Buy Access to Course
06.

Fun with Twig Extensions!

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

Head back to the article show page because... there's a little, bitty problem that I just introduced. Using the markdown filter from KnpMarkdownBundle works... but the process is not being cached anymore. In the previous tutorial, we created a cool MarkdownHelper that used the markdown object from KnpMarkdownBundle, but added caching so that we don't need to re-parse the same markdown content over and over again:

44 lines | src/Service/MarkdownHelper.php
// ... lines 1 - 2
namespace App\Service;
use Michelf\MarkdownInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Cache\Adapter\AdapterInterface;
class MarkdownHelper
{
private $cache;
private $markdown;
private $logger;
private $isDebug;
public function __construct(AdapterInterface $cache, MarkdownInterface $markdown, LoggerInterface $markdownLogger, bool $isDebug)
{
$this->cache = $cache;
$this->markdown = $markdown;
$this->logger = $markdownLogger;
$this->isDebug = $isDebug;
}
public function parse(string $source): string
{
if (stripos($source, 'bacon') !== false) {
$this->logger->info('They are talking about bacon again!');
}
// skip caching entirely in debug
if ($this->isDebug) {
return $this->markdown->transform($source);
}
$item = $this->cache->getItem('markdown_'.md5($source));
if (!$item->isHit()) {
$item->set($this->markdown->transform($source));
$this->cache->save($item);
}
return $item->get();
}
}

Basically, we want to be able to use a markdown filter in Twig, but we want it to use our MarkdownHelper service, instead of the uncached service from the bundle.

So... how can we do this? Let's create our own Twig filter, and make it do exactly what we want. We'll call it, cached_markdown.

Generating a Twig Extension

To create a custom function, filter or to extend Twig in any way, you need to create a Twig extension. These are super fun. Find your terminal and run:

php bin/console make:twig-extension

It suggests the name AppExtension, which I'm actually going to use. I'll call it AppExtension because I typically create just one extension class that will hold all of the custom Twig functions and filters that I need for my entire project. I do this instead of having multiple Twig extensions... because it's easier.

Let's go check out our new AppExtension file!

30 lines | src/Twig/AppExtension.php
// ... lines 1 - 2
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('filter_name', [$this, 'doSomething'], ['is_safe' => ['html']]),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('function_name', [$this, 'doSomething']),
];
}
public function doSomething($value)
{
// ...
}
}

Hello Twig extension! It's a normal PHP class that extends a base class, then specifies any custom functions or filters in these two methods:

30 lines | src/Twig/AppExtension.php
// ... lines 1 - 8
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
// ... lines 13 - 15
}
public function getFunctions(): array
{
// ... lines 20 - 22
}
// ... lines 24 - 28
}

Twig Extensions can add other stuff too, like custom operators or tests.

We need a custom filter, so delete getFunctions() and then change the filter name to cached_markdown. Over on the right, this is the method that will be called when the user uses the filter. Let's call our method processMarkdown. Point to that from the filter:

23 lines | src/Twig/AppExtension.php
// ... lines 1 - 5
use Twig\TwigFilter;
// ... lines 7 - 8
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('cached_markdown', [$this, 'processMarkdown'], ['is_safe' => ['html']]),
];
}
public function processMarkdown($value)
{
// ... line 20
}
}

To make sure things are working, for now, in processMarkdown(), just return strtoupper($value):

23 lines | src/Twig/AppExtension.php
// ... lines 1 - 8
class AppExtension extends AbstractExtension
{
// ... lines 11 - 17
public function processMarkdown($value)
{
return strtoupper($value);
}
}

Sweet! In the Twig template, use it: |cached_markdown:

83 lines | templates/article/show.html.twig
// ... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="col-sm-12">
<div class="show-article-container p-3 mt-4">
// ... lines 11 - 25
<div class="row">
<div class="col-sm-12">
<div class="article-text">
{{ article.content|cached_markdown }}
</div>
</div>
</div>
// ... lines 33 - 71
</div>
</div>
</div>
</div>
{% endblock %}
// ... lines 78 - 83

Oh, and two important things. One, when you use a filter, the value to the left of the filter will become the first argument to your filter function. So, $value will be the article content in this case:

23 lines | src/Twig/AppExtension.php
// ... lines 1 - 8
class AppExtension extends AbstractExtension
{
// ... lines 11 - 17
public function processMarkdown($value)
{
// ... line 20
}
}

Second, check out this options array when we added the filter. This is optional. But when you say is_safe set to html:

23 lines | src/Twig/AppExtension.php
// ... lines 1 - 8
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('cached_markdown', [$this, 'processMarkdown'], ['is_safe' => ['html']]),
];
}
// ... lines 17 - 21
}

It tells Twig that the result of this filter should not be escaped through htmlentities(). And... that's perfect! Markdown gives HTML code, and so we definitely do not want that to be escaped. You won't need this option on most filters, but we do want it here.

And... yea. We're done! Thanks to Symfony's autoconfiguration system, our Twig extension should already be registered with the Twig. So, find your browser, high-five your dog or cat, and refresh!

It works! I mean, it's super ugly and angry-looking... but it works!

Processing through Markdown

To make the extension use the MarkdownHelper, we're going to use good old-fashioned dependency injection. Add public function __construct() with a MarkdownHelper argument from our project:

31 lines | src/Twig/AppExtension.php
// ... lines 1 - 4
use App\Service\MarkdownHelper;
// ... lines 6 - 9
class AppExtension extends AbstractExtension
{
// ... lines 12 - 13
public function __construct(MarkdownHelper $markdownHelper)
{
// ... line 16
}
// ... lines 18 - 29
}

Then, I'll press Alt+Enter and select "Initialize fields" so that PhpStorm creates that $helper property and sets it:

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

Down below, celebrate! Just return $this->helper->parse() and pass it the $value:

31 lines | src/Twig/AppExtension.php
// ... lines 1 - 4
use App\Service\MarkdownHelper;
// ... lines 6 - 9
class AppExtension extends AbstractExtension
{
// ... lines 12 - 25
public function processMarkdown($value)
{
return $this->markdownHelper->parse($value);
}
}

That's it! Go back, refresh and... brilliant! We once again have markdown, but now it's being cached.