Atomic Design with Symfony UX
08 november 2023
5 minutes
Last year, Symfony UX got introduced. For those who haven't heard about Symfony UX: It is an initiative that provides a set of libraries to integrate JavaScript tools into your application. Most of the libraries make it possible to make your application interactive right from within your PHP context, without writing any line of JavaScript code yourself. That is powerful!
Since we got a few tools under our belt which we love to use in all of our projects, we wanted to do a little experiment with Symfony UX: Would it be possible to implement atomic design, which we use in all our React based projects, with the newly introduced Twig components library?
What is this Atomic design?
In Atomic design, pages can be broken down into a set of reusable - standalone components. A component can have various sizes: a small icon, a big table, ... To structure these different components, you can use following directory structure:
atoms: Atoms are the smallest building blocks. These can be simple components like buttons, labels, ...
molecules: Molecules combine atoms into a small reusable component that can be included in various places inside your application. An example could be a search form which consists out of a search field and a search button.
organisms: Organisms can be used to create standalone, portable, reusable components. These components mostly apply some concrete business logic inside your application. A common example could be a user form that can be used for both creating and updating the model.
templates: Inside a template, you can define multiple organisms, modules, ... into a reusable template. The template itself does only provide a placeholder in which content can be injected.
pages: Page components can be used to inject specific content or organisms into a template. A page is a very concrete implementation of a design, that combines all the components needed to result into the final design of the application.
If you want to learn more about atomic design, we recommend reading this article.
What are Twig components?
Twig components is a new building block provided by Symfony UX, which allows a developer to create a reusable layout block. It consists out of a simple PHP object which contains the data that is being bound to a separate Twig view. It makes it possible to re-use template "units" across all the application's templates.
Hmmm nice... But what benifit doe it give us over the plain-old Twig include way of working? Since Twig components consist out of both a PHP object and a Twig template, it allows us to validate the attributes that are being passed, add custom logics through public methods on the PHP object, better error feedback on invalid data, ...
Looks exactly what we need right? Let's dive into a very small - yet common atom to see what is going on:
Meet our button atom
One of the most basic building blocks is a button atom. Inside our application, we wanted to create a reusable button component that can have multiple themes. It must be possible to specify the label of the button from externally and optionally provide an icon at the left and/or right of the button. A simplified version of the data object of this twig component looks like this:
# src/App/Twig/Component/Atom/Button.php
namespace App\Twig\Component\Atom;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\PreMount;
#[AsTwigComponent('atoms:button', 'components/atom/buttons/button.html.twig')]
class ButtonComponent
{
public string $theme = '';
#[PreMount]
public function preMount(array $data): array
{
$resolver = new OptionsResolver();
$resolver->setDefined(['theme', ...array_keys($data)]);
$resolver->setAllowedValues('theme', ['primary' 'secondary']);
$resolver->setDefault('theme', 'primary');
return $resolver->resolve($data);
}
}
As you can see in the code above, the twig component takes a theme
argument which will be bound to the twig view in a moment.
Since we want to be able to pass down additional button properties (like disabled
, onclick
, ...) from a parent component,
additional data keys must be accepted by the options resolver as well.
The AsTwigComponent
tells Symfony to which Twig template the object must be bound.
This makes the data and methods from inside the object accessible in the twig view.
The Twig template for this button can look like this:
{# templates/components/atom/button.html.twig #}
{% set styles = {
'primary': {
button: 'py-3 px-4 bg-blue text-white',
iconLeft: 'mr-2 fill-white',
iconRight: 'ml-2 fill-white',
},
'secondary': {
button: 'py-3 px-4 bg-white text-black',
iconLeft: 'mr-2 fill-black',
iconRight: 'ml-2 fill-black',
}
} %}
<button
{{ attributes.defaults({
type: 'button',
class: 'group inline-flex items-center justify-center ' ~ styles[theme].button
}) }}
>
{% if block('iconLeft') is defined %}
<div class="{{ styles[theme].iconLeft }}">
{{ block('iconLeft') }}
</div>
{% endif %}
{%block children %}{%endblock%}
{% if block('iconRight') is defined %}
<div class="{{ styles[theme].iconRight }}">
{{ block('iconRight') }}
</div>
{% endif %}
</button>
There are a few things to note about this twig view:
Since we are using Tailwind CSS to style our applications, having a reusable twig component allows us to centrally manage the style for any button inside our application in one place. If we want to change the design, the tailwind classes inside the button component are changed in 1 place - and the style are applied to every other component that uses this button. Awesome!
Additional attributes are being passed down to the <button>
-tag with a default classes. This allows components that use the button to append additional classes to the button, whilst still applying the default once. It is also the code that links additional attributes that are being passed from parent components (like disable
, onclick
, ...) into the button. The default attribute type
can be overwritten from the parent component.
There are 3 injectable content blocks defined: An optional iconLeft
and iconRight
block that can be customized from the parent component. And a required children
block that contain e.g. a simple text label or any advanced HTML. This HTML can be constructed as raw HTML or by yet another atom.
The usage of this button component, can look like this:
{% component 'atoms:button' with {
class: 'mr-4',
theme: 'primary',
disabled: false,
onclick: 'alert("hello world")' | escape('html_attr')
} %}
{% block iconLeft %}
{{ component('atoms:svg-icon', {icon: 'check',size: 'normal'}) }}
{% endblock %}
{% block children %}{{ 'Order now'|trans}}{% endblock %}
{% endcomponent %}
The code above will render a button in the primary theme (blue button with white text). If the user clicks on it, it will display an in-browser alert with the message "hello world". The button will be displayed with solely an icon on the left of the central "Order now" message.
It takes some "getting-used-to" the twig syntax, but the result looks very close to how things are done in a front-end framework like React. It allows us to create both very small atoms and very complex organisms based on the same principles that we've shown you above. Let's explore how we can build a molecule that contains an atom.
Building a search molecule
Imagine you want to build a molecule that you want to include in e.g. a list overview organism. This molecule template can combine multiple atoms into one bigger whole. The template could look like this:
{# templates/components/molecule/search-form.html.twig #}
{% component 'atoms:form' with {action: '/search', method: 'GET'} %}
{% block children %}
{{ component('atoms:input', {placeholder: 'Search', name: 'query'}) }}
{% component 'atoms:button' with {theme: 'primary', type: 'submit'} %}
{% block iconLeft %}
{{ component('atoms:svg-icon', {icon: 'search', size: 'normal'}) }}
{% endblock %}
{% block children %}{{ 'Search'|trans}}{% endblock %}
{% endcomponent %}
{% endblock children %}
{% endcomponent %}
It combines a form, input and button atom into a reusable form which you can include inside your list organism. Current implementation results in a rather dump component that will refresh the page with a search query attached once submitted.
Symfony UX provides additional tools like Turbo frames that will perform this action in the background of your browser.
If you want more control about the exact form submission, you can change the AsTwigComponent
into AsLiveComponent
. This is another Symfony UX library that allows you to validate and rerender the data server-side through PHP code rather than through Javascript.
Alternatively, if you don't want to constantly rerender this molecule on your server, you can still make this molecule more dynamic by adding a Stimulus (Javascript) controller on top of it.
You might be wondering: Why would you create a form inside twig rather than using a Symfony form? Good question! We wondered this ourselves as well and found a great solution. It is possible to create a custom Symfony form theme that renders out every form field as an atomic component. We will tell you more about this approach in a next blog post!
Conclusion
Symfony UX provides us a well-designed and flexible components library that we can use to structure all separate components inside our PHP applications. It allows us to style our applications with the Tailwind, without making it hard to maintain in the long run. Adding atomic components to our Symfony applications changed the way we look at Twig in a maintainable and stable way.
Sure, there are some rough edges and it the syntax is not completely the same as you would make it look in e.g. React. However, the resulting set of components is a real bless if you want to build a large, maintainable, stable, and consistent PHP application.