Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Making a Twig Extension (Filter)

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 $10.00

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:

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

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

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

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

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

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

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

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

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

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

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

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

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

Leave a comment!

9
Login or Register to join the conversation
Mamour W. Avatar
Mamour W. Avatar Mamour W. | posted 3 years ago

Hello, can't find the next course about database ?

1 Reply

Hey Mamour W.

At the moment we don't have a Doctrine tutorial based on Symfony5 but we do have a couple you might be interested. These are based on Symfony4 but majority of the content is still relevant
https://symfonycasts.com/sc...
https://symfonycasts.com/sc...

Cheers!

Reply
gazzatav Avatar
gazzatav Avatar gazzatav | posted 1 year ago

All was fine until I moved the APP_ENV= variable into .env.local. After this I got
The option "dsn" with value ""https://xxxxxxxxxxxxxxxxxxx..."" is invalid. But not with xxxxxx... :)

Have I missed something?

Reply

Ryan, you're just awesome I don't know what name I can give you but it seems you already float in space so that's enough.
Cheers.

Reply
triemli Avatar
triemli Avatar triemli | posted 2 years ago | edited

Hi guys!
Can you help me with some twig extension?
I want to embed partial controller in a template like this. Everything works, but I can't use dynamic name:
<b>dynamicName</b> is App\Controller\ArticleController::recentArticles

{{ render(controller(dynamicName, { section: section})) }}

Got an error

<blockquote>Compile Error: Cannot declare class App\Controller\ArticleController, because the name is already in use</blockquote>

Then I tried to create an extension:



class WidgetExtension extends AbstractExtension
{
    private Environment $environment;
    private $fragmentHandler;

    public function __construct(Environment $environment, $fragmentHandler)
    {
        $this->environment = $environment;
        $this->fragmentHandler = $fragmentHandler;
    }

    public function getFunctions()
    {
        return [
            new TwigFunction('widget', [$this, 'handle']),
        ];
    }

    public function handle($name, $params)
    {
        $ref = HttpKernelExtension::controller($name, $params);

        return $this->fragmentHandler->render($ref);
    }

But got same error "the name is already in use"

Reply

Hey triemli!

Woh! This is very interesting! I more or less know how the render(controller()) functionality works, and I can't think of ANY reason why using a dynamic value would be a problem. I'm not saying your wrong about the error... but It think something else is going on. I especially think that since you tried to do this with a custom Twig extension and got the same result. Here is what I would try to test our theory: simply render:


{{ render(controller('App\\Controller\\ArticleController::recentArticles', { section: section})) }}

Does that work? Or do you get the same error? If it DOES work... then triple check your dynamicName variable. If it does NOT work, try to render a different controller from a different class. Usually (but not always) these "because the name is already in use" errors are because there is a typo in some class or namespace in some file and you, accidentally have 2 different "ArticleController" classes in different files.

Cheers!

Reply
Matteo S. Avatar
Matteo S. Avatar Matteo S. | posted 3 years ago

At the end it says in the next course we're gonna learn to use the database: where is that next course???

Reply

Hey Teo!

Thank you for your interest in SymfonyCasts tutorials! Yes, it will be covered in the next course, but we're working on that course right now. It should be released next soon, most probably after we fully released VueJS course, but I don't have any estimations yet. For now, the related most recent course about DB is this one: https://symfonycasts.com/sc... - but it's based on Symfony 4. If you don't want to wait for Symfony 5 related course - you may follow Symfony 4 one. The concepts should be the same. And of course, if you will get stuck somewhere following that course -just leave us a comment below the video and we will try to help you moving forward.

Cheers!

Reply
Cat in space

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

This tutorial also works great for Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.3.0 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.1
        "sentry/sentry-symfony": "^4.0", // 4.0.3
        "symfony/asset": "5.0.*", // v5.0.11
        "symfony/console": "5.0.*", // v5.0.11
        "symfony/debug-bundle": "5.0.*", // v5.0.11
        "symfony/dotenv": "5.0.*", // v5.0.11
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/framework-bundle": "5.0.*", // v5.0.11
        "symfony/monolog-bundle": "^3.0", // v3.6.0
        "symfony/profiler-pack": "*", // v1.0.5
        "symfony/routing": "5.1.*", // v5.1.11
        "symfony/twig-pack": "^1.0", // v1.0.1
        "symfony/var-dumper": "5.0.*", // v5.0.11
        "symfony/webpack-encore-bundle": "^1.7", // v1.8.0
        "symfony/yaml": "5.0.*" // v5.0.11
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.15", // v1.23.0
        "symfony/profiler-pack": "^1.0" // v1.0.5
    }
}
userVoice