If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeHead 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
.
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!
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.
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!
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.
Hey @Chris
Where did you get this error message? Does it produce some issues with course for you?
Cheers!
<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?
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!
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 %}
Hey Mike!
Thank you! I do my best :) I'm happy it helped you, and thanks for sharing your solution with others!
Cheers!
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']]
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.
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!
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));
}),
];
}
}
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!
$this->markdownHelper->parse($value) and not $this->helper->parse($value) as said before the last code block. :)
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!
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!
// 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
}
}
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)