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 SubscribeWhen 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.
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.
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) | |
{ | |
// ... | |
} | |
} |
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.
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!
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!
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?
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.
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"
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!
At the end it says in the next course we're gonna learn to use the database: where is that next course???
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!
// 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
}
}
Hello, can't find the next course about database ?