Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Fun with Twig Extensions!

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

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!

19
Login or Register to join the conversation
Default user avatar
Default user avatar Dylan Delobel ☕ | posted 5 years ago

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)

1 Reply

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!

2 Reply
Chris Avatar

Sort of an off-topic. It seems that the error message re:H264 decoding seems outdated (Firefox/Linux). Either ffmpeg or libav packages would work instead of gstreamer. Packman has all packages if on SUSE.

Reply

Hey @Chris

Where did you get this error message? Does it produce some issues with course for you?

Cheers!

Reply
Mike P. Avatar
Mike P. Avatar Mike P. | posted 3 years ago | edited

<b>Goal:</b>
Due to SEO reasons, I need a couple of <meta> tags on every page.
Lets say, we have 3 blocks:


<title>{% block title %}Homepage Title{% endblock %}</title>
<meta name="description" content="{% block description %}Homepage Desc{% endblock %}">
<meta rel="canonical" href="{% block canonical %}{{ app.request.uri }}{% endblock %}">

That would be working, every block could be overwritten.
But its not very "error resistant", I mean a dev could miss any of this blocks on a children twig templates.

<b>Question:</b>
Is there a way to "enforce" that every 3 blocks should be set (to avoid errors if the dev "forget" one block?
What is the best solution?

I thought about a custom twig function, like in this tutorial, with 3 parameters and one is optional (canonical)
Is a twig function for this use case the way to go?

Reply

Hey Mike,

Interesting question! As far as I know it's impossible to do with Twig block out of the box. Probably, the best solution I can see is to use a variable in the parent template that will be declared in the child template. If you didn't set that variable - you will see the error on the page.

I think you can do something similar with custom Twig function, but you would also need a listener probably. Like you inject a special service in that Twig extension for your custom Twig function and if the specific argument was passed - remember it with that service, like a boolean flag. Then in the listener, you listen for the latest event, .e.g. terminate event and check if that bool flag was set on that service. If no - throw an exception with a clear error message.

But fairly speaking, this work sounds like a job for tests, so you can just write test that will ping every unique route you have and check if the response contains specific content. Though, this test may take some time to execute, depends on how many unique routes you have.

I hope this helps!

Cheers!

1 Reply
Mike P. Avatar
Mike P. Avatar Mike P. | Victor | posted 3 years ago | edited

I'am always impressed of your detailed answers!
You helped me a lot, thank you Victor!

My solution:


        {% block seo %}
            <title>{{ seo_title }}</title>
            <meta name="description" content="{{ seo_description }}">
            {% if (seo_robots) is not defined %}
                {% set seo_robots = 'index,follow' %}
            {% endif %}
            <meta name="robots" content="{{ seo_robots }}">
            {% if seo_robots == 'index,follow' %}
                <meta rel="canonical" href="{{ seo_canonical }}">
            {% endif %}
        {% endblock %}
Reply

Hey Mike!

Thank you! I do my best :) I'm happy it helped you, and thanks for sharing your solution with others!

Cheers!

Reply
Richard Avatar
Richard Avatar Richard | posted 3 years ago

I missed the part where we removed the debug check and didnt cache in debug mode.

Reply
Dominik Avatar
Dominik Avatar Dominik | posted 4 years ago

I had a problem with auto escaping even when added "
['is_safe' => ['html']]
" argument.

I checked twig documentation to find out why escaping is still on and there is solution, following documentation:

"Some filters may need to work on input that is already escaped or safe, for
example when adding (safe) HTML tags to originally unsafe output. In such a
case, set the pre_escape option to escape the input data before it is run
through your filter:"

So if someone will have the same problem just add this argument :

['pre_escape' => 'html', 'is_safe' => ['html']]

instead of only
['is_safe' => ['html']]

Reply

Nice find! Thanks for the note Dominik!

Reply
Default user avatar
Default user avatar Kristof Kreimeyer | posted 4 years ago

Hi Guys,
i get the following error when adding the constructor function :

Cannot autowire service "App\Twig\AppExtension": argument "$markdownHelper" of method "__construct()" has type "App\Service\MarkdownHelper" but this class was not found.

Wtf ?!

The code is exactly the same like the tutorial code.

Reply

Hey Kristof Kreimeyer!

Ah no, lame! Let's see if we can figure this out :). Fortunately, these errors are designed to be pretty good. The key part is the end of it:

has type "App\Service\MarkdownHelper" but this class was not found

This tells me that your type-hint is correct (this IS the correct class that we should be type-hinting) but, for some reason, that's not found. That IS definitely mysterious... especially because we created that in a previous tutorial (so if you downloaded the start code, that file should be perfect). To experiment, can you reference that class directly in, for example, a controller? Specifically, the ArticleController::show() method should already be using it (before this chapter):


    /**
     * @Route("/news/{slug}", name="article_show")
     */
    public function show($slug, MarkdownHelper $markdownHelper, SlackClient $slack)
    {

Does this endpoint work for you? Or do you get a similar error?

Cheers!

Reply
Krzysztof K. Avatar
Krzysztof K. Avatar Krzysztof K. | posted 4 years ago | edited

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));
            }),
        ];
    }
}
Reply

Hey Krzysztof K.!

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!

Reply
Default user avatar

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

Reply

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!

Reply
Ad F. Avatar

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

Reply
Victor Avatar Victor | SFCASTS | Ad F. | posted 5 years ago | edited

Hey Ad F. ,

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!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.1.4
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.0.4
        "symfony/console": "^4.0", // v4.0.14
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/framework-bundle": "^4.0", // v4.0.14
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/twig-bundle": "^4.0", // v4.0.4
        "symfony/web-server-bundle": "^4.0", // v4.0.4
        "symfony/yaml": "^4.0" // v4.0.14
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.4
        "fzaninotto/faker": "^1.7", // v1.7.1
        "symfony/debug-bundle": "^3.3|^4.0", // v4.0.4
        "symfony/dotenv": "^4.0", // v4.0.14
        "symfony/maker-bundle": "^1.0", // v1.4.0
        "symfony/monolog-bundle": "^3.0", // v3.1.2
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.0.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.0.4
    }
}
userVoice