Buy
Buy

Fun with Twig Extensions!

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:

... 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!

... 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:

... 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:

... 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):

... 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:

... 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:

... 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:

... 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:

... 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:

... 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:

... 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.

Leave a comment!

  • 2018-09-18 weaverryan

    Hey Krzysztof Krakowiak!

    Great question! And a very interesting solution! Honestly, I didn't know you could do this :).

    Let me describe another solution. Actually, this solution would *not* work in this case, but it's *usually* what I do.

    1) You find where the function/filter you want is implemented. For example: https://github.com/symfony/...

    2) Then, you reverse engineer what it is doing and do that yourself. In almost all cases, a Twig function/filter is just calling a method on some service. So, you can just fetch that same service and call the same method. This does NOT work in this case because, oddly, all the logic is actually *inside* that Twig Extension. So, your solution is probably the best.

    Cheers!

  • 2018-09-18 Krzysztof Krakowiak

    How I can use existing twig function in an extension?

    ---> EDIT <---

    My solution:


    class AppTwigExtension extends AbstractExtension
    {

    private $twig;

    public function __construct(Twig_Environment $twig)
    {
    $this->twig = $twig;
    }

    public function getFunctions(): array
    {
    return [
    new TwigFunction('rev1_path', function($value) {
    $function = $this->twig->getFunction('absolute_url');
    return str_replace('rev2/', '', $function->getCallable()[0]->generateAbsoluteUrl($value));
    }),
    ];
    }
    }
  • 2018-09-17 Victor Bocharsky

    Hey Chris,

    Good catch! Thanks for reporting it. But actually that line is in sync with the video, we use "$this->helper" in this screencast, but in the code it's "$this->markdownHelper" - a little discrepancy we had in this screencast :)

    Cheers!

  • 2018-09-15 Chris

    $this->markdownHelper->parse($value) and not $this->helper->parse($value) as said before the last code block. :)

  • 2018-06-04 Victor Bocharsky

    Hey cybernet2u ,

    Yes, you're right. Maker bundle had autogenerated us a few examples with TwigFilter and TwigFunction, but then we removed that TwigFunction at all, so the use statement no needed anymore. But nothing harmful if you keep it, well, at except PhpStorm warning about unused namespace ;)

    Cheers!

  • 2018-06-02 cybernet2u

    in my opinion, use Twig\TwigFunction; should be removed from AppExtension Service :)

  • 2018-05-03 weaverryan

    Hey Dylan Delobel ☕!

    Sure :). We did that work in this tutorial: https://knpuniversity.com/s..., but most specifically, this chapter: https://knpuniversity.com/s.... Basically, we use Symfony's built-in cache system to create a new MarkdownHelper service that is processes the markdown, but caches the results. Then, we're using that in this tutorial.

    Let me know if that helps! Cheers!

  • 2018-05-03 Dylan Delobel ☕

    Hi Ryan,

    Could you point me out where exactly you did the markdown cache ? (There many cache turotial i would find the one for the markdwon)