Twig Components
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeToday, we get to talk about one of my favorite new-ish PHP libraries: Twig Components. They... do kind of what their name sounds like. But let's dive in and see them in action.
Installing Twig Components
Find your terminal and install the package with:
composer require symfony/ux-twig-component
Twig Components is a pure PHP library... and an easy way to think about it is: a fancier and more powerful way to do a Twig include()
.
Over in our browser, open the edit page in a new tab so we can see the full page. Then open the form for this: _form.html.twig
. When you use Tailwind, creating a button is... kind of a lot of work. Twig Components will help us centralize this.
make:twig-component
Because this is our first Twig Component, let's be lazy and generate it. Run:
php bin/console make:twig-component
Call it Button... and say no to a live component. We get to talk about those in 2 days.
This created two files. The first lives in src/Twig/Components/Button.php
:
// ... lines 1 - 2 | |
namespace App\Twig\Components; | |
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; | |
class Button | |
{ | |
} |
It's... an empty class. And it's not even needed yet! In fact, we could delete this and the first half of today would work fine without it. We'll come back to this later.
The more important thing is: templates/components/Button.html.twig
. A pretty boring-looking Twig template. Change the div to be a <button>
, and inside, I'll say, "Press me!":
<button {{ attributes }}>Press me!</button> |
To use this, over in _form.html.twig
, say {{ component('Button') }}
:
{{ form_start(form) }} | |
// ... lines 2 - 3 | |
{{ component('Button', { | |
// ... lines 5 - 6 | |
}) }} | |
// ... lines 8 - 11 | |
{{ form_end(form) }} |
If we just did that, it would work. We get a button that says, "press me".
Passing Attributes to a Component
One of the first interesting things about Twig Components is that you can pass attributes into them. As a second argument, pass formnovalidate
set to true
, then class
... copy this long class list... and paste:
{{ form_start(form) }} | |
// ... lines 2 - 3 | |
{{ component('Button', { | |
formnovalidate: true, | |
class: 'px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700', | |
}) }} | |
// ... lines 8 - 11 | |
{{ form_end(form) }} |
When we do that, we get an error... because I forgot my closing comma. Better. As I was saying, when we do that... we see a button with those Tailwind classes! This is thanks to a cool attributes
variable that we have in any Twig Component template. It collects what we pass into the component - called props
- and renders them.
The Optional HTML Syntax
One of my favorite features of Twig Components is that it has an optional, but wonderful, HTML syntax. Instead of the Twig function, we can say <twig:Button>
. Now props are passed like normal HTML attributes. I'll copy them from the real <button>
tag and paste:
{{ form_start(form) }} | |
// ... lines 2 - 3 | |
<twig:Button | |
formnovalidate | |
class="px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700" | |
/> | |
// ... lines 8 - 11 | |
{{ form_end(form) }} |
What does it look like? The same darn thing! This special syntax comes from Twig Components and is for rendering Twig Components. Some people are "meh" on this syntax, while others love it. Choose whatever you want. I like it because it feels like a native HTML element. And if you've ever used a front-end framework like React, it will feel natural.
Passing Content to the Twig Component
But, we still have hard-coded "Press me!" content. That's not very helpful. To make this dynamic, we can use a block. That's right, a good old-fashioned Twig block! I called this one content
:
<button {{ attributes }}>{% block content %}{% endblock %}</button> |
To pass in that block, copy the button label below, change this to a not self-closing tag, paste... then add the closing tag:
{{ form_start(form) }} | |
// ... lines 2 - 3 | |
<twig:Button | |
formnovalidate | |
class="px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700" | |
> | |
{{ button_label|default('Save') }} | |
</twig:Button> | |
// ... lines 10 - 13 | |
{{ form_end(form) }} |
And... it works! What!? When you put content between the Twig component HTML tags, it becomes a block called content
. That's just built in. If you also had other blocks in your component and needed to pass those in too, you can do that. And you would specify those using the normal block
, endblock
syntax. But you get this content
block for free, which looks fantastic.
Celebrate by removing our old HTML button:
{{ form_start(form) }} | |
{{ form_widget(form) }} | |
<twig:Button | |
formnovalidate | |
class="px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700" | |
> | |
{{ button_label|default('Save') }} | |
</twig:Button> | |
{{ form_end(form) }} |
Default Component Attributes
But remember: the goal is to make buttons easier to create. And needing to specify all of these classes is... entirely the problem we want to fix! Copy those and delete the class
attribute entirely:
{{ form_start(form) }} | |
// ... lines 2 - 3 | |
<twig:Button formnovalidate> | |
{{ button_label|default('Save') }} | |
</twig:Button> | |
{{ form_end(form) }} |
In the component template, we could add a class
attribute right here and paste. But instead, call attributes.defaults
, pass that an array with class:
then the class string:
<button {{ attributes.defaults({ | |
class: 'px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700', | |
}) }}>{% block content %}{% endblock %}</button> |
This will let us add more classes when we use this component. We'll do that in minute.
Over on the site... it still looks great! Now suppose, in this one situation, we need an extra class - hover:animate-wiggle
- to make our button more fun:
{{ form_start(form) }} | |
// ... lines 2 - 3 | |
<twig:Button formnovalidate class="hover:animate-wiggle"> | |
// ... line 5 | |
</twig:Button> | |
{{ form_end(form) }} |
This is a custom CSS animation I invented... so down in tailwind.config.js
, I'll paste the wiggle
... and its keyframe:
// ... lines 1 - 3 | |
module.exports = { | |
// ... lines 5 - 9 | |
theme: { | |
extend: { | |
animation: { | |
// ... line 13 | |
wiggle: 'wiggle 1s ease-in-out infinite', | |
}, | |
keyframes: { | |
// ... lines 17 - 20 | |
wiggle: { | |
'0%, 100%': { transform: 'rotate(-3deg)' }, | |
'50%': { transform: 'rotate(3deg)' }, | |
} | |
}, | |
}, | |
}, | |
// ... lines 28 - 33 | |
} |
Ok, refresh and hover! Pointless... but so fun! The important part is that we see the normal classes that come from the component template and the extra class at the end.
Passing Variables to a Component
Could we now reuse the component for the delete button? Let's try! This lives in _delete_form.html.twig
. Change this to <twig:
big "B" Button
. Then most of these classes are in the component already. We only need the ones related to color:
<form method="post" action="{{ path('app_voyage_delete', {'id': voyage.id}) }}" onsubmit="return confirm('Are you sure you want to delete this item?');"> | |
// ... lines 2 - 3 | |
<twig:Button class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:ring-red-300 focus:outline-none"> | |
Delete | |
</twig:Button> | |
</form> |
And... it works! But... kind of by accident. If we inspect that element, it has the bg-green-600
from the twig component template and the bg-red-600
. You might think... that makes sense! The later one overrides the earlier one right?
Actually, no. There's no rule that says that this one should win over this one or the green should win over the red. The reason red is winning is... luck! By chance, when Tailwind generated the CSS file, the bg-red-600
was, apparently, generated later in the file. So, it's winning. In Tailwind, you need to avoid competing classes because the result isn't guaranteed.
What we really want to do is create different variants of the button. I'd like to be able to say something like variant="danger"
:
<form method="post" action="{{ path('app_voyage_delete', {'id': voyage.id}) }}" onsubmit="return confirm('Are you sure you want to delete this item?');"> | |
// ... lines 2 - 3 | |
<twig:Button variant="danger" class="text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:ring-red-300 focus:outline-none"> | |
// ... line 5 | |
</twig:Button> | |
</form> |
And... over in the other template, variant="success"
:
{{ form_start(form) }} | |
// ... lines 2 - 3 | |
<twig:Button | |
// ... line 5 | |
variant="success" | |
// ... line 7 | |
> | |
// ... line 9 | |
</twig:Button> | |
{{ form_end(form) }} |
Right now, no surprise, that adds a variant="danger"
attribute. That's not what I want: I want to use this value in my component to conditionally render different classes.
This is finally where our PHP class comes in handy. To convert a prop that we pass into our component from an attribute to a variable, we can add a public property with the same name: public string $variant = 'default';
:
// ... lines 1 - 6 | |
class Button | |
{ | |
public string $variant = 'default'; | |
} |
And now that we have a public property called variant
, we get a local variable inside of Twig called variant
. Watch {{ variant }}
:
<button {{ attributes.defaults({ | |
class: 'px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700', | |
}) }}>{{ variant }}{% block content %}{% endblock %}</button> |
And now... we see it in both spots! Danger up here, success down there.
Adding a Component PHP Method
Ok: we now need to use the variant
variable to conditionally render different classes. We need... kind of a switch-case statement to map each variant to a set of classes. Writing something like that in Twig... isn't super fun.
But remember: we have a Twig component PHP class that's bound to this template. And we can add methods there! I'll paste in a new public function called getVariantClasses()
:
// ... lines 1 - 7 | |
class Button | |
{ | |
// ... lines 10 - 11 | |
public function getVariantClasses(): string | |
{ | |
return match ($this->variant) { | |
'default' => 'text-white bg-blue-500 hover:bg-blue-700', | |
'success' => 'text-white bg-green-600 hover:bg-green-700', | |
'danger' => 'text-white bg-red-600 hover:bg-red-700 focus:ring-4 focus:ring-red-300 focus:outline-none', | |
default => throw new \LogicException(sprintf('Unknown button type "%s"', $this->variant)), | |
}; | |
} | |
} |
It has a match
statement... which based on $this->variant
, returns a different set of classes.
One of the superpowers of Twig components is that this Button
object is available inside the component template as a variable called this
. That means we can go to the end of the class
list, remove the color-specific ones, then concatenate with a ~
and this.variantClasses
:
<button {{ attributes.defaults({ | |
class: 'px-4 py-2 border border-transparent text-sm font-medium rounded-md '~this.variantClasses, | |
}) }}>{% block content %}{% endblock %}</button> |
Go check it. Yes! We have green down here... and red up there! To really prove it's working, on the delete button, remove the extra classes:
<form method="post" action="{{ path('app_voyage_delete', {'id': voyage.id}) }}" onsubmit="return confirm('Are you sure you want to delete this item?');"> | |
// ... lines 2 - 3 | |
<twig:Button variant="danger"> | |
Delete | |
</twig:Button> | |
</form> |
I love the way that looks in code... and on the site.
Pointing Tailwind at your Component PHP Classes
Though, one detail. Tailwind scans our source files, finds all the Tailwind classes we're using and includes those in the final CSS file. And because we're now including classes inside PHP, we need to make sure Tailwind sees those.
In tailwind.config.js
, on top, I'll paste in one more line to make it scan our Twig component PHP classes:
// ... lines 1 - 3 | |
module.exports = { | |
content: [ | |
// ... lines 6 - 8 | |
"./src/Twig/Components/**/*.php" | |
], | |
// ... lines 11 - 34 | |
} |
Changing the Component Tag Name
Ok, we're nearly done for today - but I want to celebrate and use the new component in one more spot: on the voyage index page, for the "New Voyage" button.
Open index.html.twig
... change this to a <twig:Button>
... then we can remove most of these classes. The bold is specific to this spot I think:
// ... lines 1 - 4 | |
{% block body %} | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
class="flex justify-between" | |
> | |
// ... lines 10 - 11 | |
<twig:Button | |
href="{{ path('app_voyage_new') }}" | |
data-turbo-frame="modal" | |
class="flex items-center space-x-1 font-bold" | |
> | |
<span>New Voyage</span> | |
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 inline" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" /><path d="M9 12h6" /><path d="M12 9v6" /></svg> | |
</twig:Button> | |
</div> | |
// ... lines 21 - 40 | |
</div> | |
{% endblock %} |
When we refresh... it renders! Though... when I click... nothing happens! You probably saw why: this is now a button, not an a
tag.
That's okay: we can make our component just a bit more flexible. In Button.php
, add another property: string $tag
defaulting to button
:
// ... lines 1 - 7 | |
class Button | |
{ | |
// ... line 10 | |
public string $tag = 'button'; | |
// ... lines 12 - 21 | |
} |
Then in the template, use {{ tag }}
for the starting tag and the ending tag:
<{{ tag }} {{ attributes.defaults({ | |
// ... line 2 | |
}) }}>{% block content %}{% endblock %}</{{ tag }}> |
Finish in index.html.twig
: pass tag="a"
:
// ... lines 1 - 5 | |
<div class="m-4 p-4 bg-gray-800 rounded-lg"> | |
<div | |
class="flex justify-between" | |
> | |
// ... lines 10 - 11 | |
<twig:Button | |
tag="a" | |
// ... lines 14 - 16 | |
> | |
// ... lines 18 - 19 | |
</twig:Button> | |
</div> | |
// ... lines 22 - 41 | |
</div> | |
{% endblock %} |
Now over here... when we click... got it!
That's it! I hope you love Twig components as much as I do. But they can do even more! I didn't tell you how you can prefix any attribute with :
to pass dynamic Twig variables or expressions to a prop. We also didn't discuss that the component PHP classes are services. Yea, you can add an __construct()
function, autowire other services, like VoyageRepository
, then use those to provide data to your template... making the entire component standalone and self-sufficient. That's one of my favorite features.
Tomorrow we're going to keep leveraging Twig components to create a modal component... then see just how easily we can use modals whenever we want.
I think you have an example of getting "bad luck" with those "competing classes" when you change the "New voyage" button to use the Twig component. Seems like
font-medium
wins overfont-bold
here.But maybe I'm wrong since CSS is not really my favorite... watch closely from 9:20
Oh and could you please, from time to time, remind us of running the
tailwind:build
command please? :)