Découverte de Symfony UX et composants Twig
Qui suis-je pour parler en ces lieux ?
Je m'appelle Kévin Nadin, je travaille chez Darkmira en tant que développeur PHP
Vous pourrez retrouver ces slides et mes anciennes conférences sur: https://slides.com/kevinjhappy

Symfony UX, quésaco ?
Implémentation avec Twig et PHP d'outils Javascript classes ;)
Beaucoup d'applications possibles :
-
Champ AutoComplete
-
Infinite scroll
-
Formulaire dans une modal
-
Navigation sur une map
-
Système de votes
...
Pour résumer, cela permet de créer des effets stylés en front… sans être expert en front !
Je vais vous présenter quelques applications que j'ai eu l'occasion d'utiliser sur des projets
Twig Components
Pour l'exemple, nous allons implémenter une page permettant de donner les détails d'un utilisateur
<div class="...">
{{ component('ShowUser', {
user: app.user
}) }}
</div>Coté Twig l'appel au composant se fait comme ceci
<?php
namespace App\Application\Twig\Components;
...
use App\Entity\User;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
// ou on précise le nom du composant si le nom de la classe est différent
#[AsTwigComponent('ShowUser')]
class ShowUser
{
// ici la valeur est rempli lors de l'appel au composant
public User $user;
...
}Ce qui va appeler la classe PHP suivante
<div class="...">
<a href="{{ path('user_profile', {'id': this.getId}) }}">
<img src="{{ asset(this.getAvatar) }}" alt="user" />
</a>
<p>
{{ this.getName }}
</p>
</div>Avec le template correspondant ShowUser.html.twig
Ici on peut voir que l'on appelle this.getId, this.getAvatar et this.getName
Ces fonctions sont codé dans la partie PHP
<?php
...
#[AsTwigComponent('ShowUser')]
class ShowUser
{
public User $user;
public function getAvatar(): string
{
$avatarDir = './assets/images/avatars/';
if (null !== $this->user->getAvatar()) {
return $avatarDir.$this->user->getAvatar();
}
return $avatarDir.'default.png';
}
public function getId(): int
{
return $this->user->getId()
}
public function getName(): string
{
return ucfirst($this->user->getFirstname()).' '.ucfirst($this->user->getLastname());
}
}Dans notre classe les fonctions rajoutés seront disponible directement coté Twig
On peut aussi rajouter des variables inclus à l'appel du component sans les inclure en PHP
<div class="...">
{{ component('ShowUser', {
user: app.user,
showAvatar : true
}) }}
</div><div class="...">
<a href="{{ path('user_profile', {'id': this.getId}) }}">
{% if showAvatar is defined and showAvatar %}
<img src="{{ asset(this.getAvatar) }}" alt="user" />
{% endif %}
</a>
<p>
{{ this.getName }}
</p>
</div>Pour la config du dossier des composants Twig:
twig_components.yaml
twig_component:
anonymous_template_directory: 'app/components/'
defaults:
# Namespace & directory for components
App\Application\Twig\Components\: 'app/components/'
Live Components
Dans cet exemple, nous allons afficher une liste d'utilisateur avec un scroll infini en fonction de leur pays
L'appel au composant
<div class="...">
{{ component('UserListGrid', {countryCode: 'FR'}) }}
</div><?php
namespace App\Application\Twig\Components;
...
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
#[AsLiveComponent('UserListGrid')]
class UserListGrid {
...
}Qui appellera la classe
La classe va utiliser des propriétés live LiveProps
class UserListGrid
{
use ComponentToolsTrait;
use DefaultActionTrait;
#[LiveProp(writable: true)]
public string $countryCode;
private Paginator $items;
private const PER_PAGE = 10;
#[LiveProp]
public int $page = 1;
#[ExposeInTemplate('per_page')]
public function getPerPage(): int
{
return self::PER_PAGE;
}
...
}
Et implémenter plusieurs fonctions
class UserListGrid
{
...
public function __construct(private UserRepository $userRepository) {}
public function getItems(): array|\Traversable
{
$this->items = new Paginator(
$this->userRepository->getUsersByCountry(
$this->countryCode, $this->page, self::PER_PAGE
)
);
return $this->items->getIterator();
}
#[LiveAction]
public function more(): void
{
++$this->page;
}
// hasMore permet de vérifier si la fin des résultats a été atteinte
public function hasMore(): bool
{
return \count($this->items) > ($this->page * self::PER_PAGE);
}
}On utilise Doctrine pour l'exemple, la fonction doit renvoyer un QueryBuilder pour pouvoir utiliser new Paginator()
class UserRepository extends ServiceEntityRepository {
...
public function getUsersByCountry(
string $countryCode, $page = 0, $perPage = 0
): QueryBuilder {
$queryBuilder = $this->createQueryBuilder('user');
->where('user.countryCode = :countryCode');
->setParameter('countryCode', $countryCode);
if ($page > 0 && $perPage > 0) {
$queryBuilder->setFirstResult(($page - 1) * $perPage)
->setMaxResults($perPage);
}
return $queryBuilder;
}
}Coté Twig il faut une certaine rigueur dans les class et id :
- La classe doit rester cohérente entre la page courante et les pages précédentes
- Les id doivent garder la rigueur de l'incrémentation de chaque page pour chaque élément
{# attributes ici est géré par symfony, il donnera un id et d'autres infos #}
<div class="UserListGrid" {{ attributes }}>
{# cette étape permet de garder en mémoire les résultats précédents #}
{# data-live-ignore true empèche les méthode LiveAction de se déclencher #}
{% if page > 1 %}
<div class="UserListGrid_page" id="items--{{ page - 1 }}-{{ per_page }}"
data-live-ignore="true"></div>
{% endif %}
{# page courrante #}
{% for item in this.items %}
<div class="UserListGrid_page" id="items--{{ page }}-{{ loop.index }}">
{{ component('ShowUser', { user: item }) }}
</div>
{% endfor %}
...Il faut implémenter le passage à la page suivante, la méthode LiveAction vu côté PHP entre en jeu
<div class="UserListGrid" {{ attributes }}>
{% if page > 1 %}
<div class="UserListGrid_page" id="items--{{ page - 1 }}" data-live-ignore="true">
</div>
{% endif %}
{% for item in this.items %}
<div class="UserListGrid_page" id="items--{{ page }}">
{{ component('ShowUser', { user: item }) }}
</div>
{% endfor %}
{% if this.hasMore %}
{# ici nous allons appeler une méthode dans le component
avec le #LiveAction appelé "more" #}
<button data-action="live#action" data-live-action-param="more">
Charger la suite
</button>
{% endif %}
</div>Cliquer sur "Page suivante" va appeler la méthode PHP dans le LiveComponent
class UserListGrid
{
#[LiveProp]
public int $page = 1;
#[LiveAction]
public function more(): void
{
++$this->page;
}
}Avec $page qui est une propriété Live, alors Symfony peut la mettre à jour dynamiquement et charger la nouvelle page, en prenant l'itération suivante de getItems
Avec cette implémentation, nous avons une page en scroll infini avec un bouton chargeant les éléments sans changer de page
Aller plus loin en rajoutant du JS
En reprenant notre exemple on peut supprimer le bouton "Page suivante" pour charger automatiquement la suite une fois arrivé en bas des éléments chargés
Coté Twig on peut inclure des controllers JS Stimulus facilement
{# va charger le fichier appears-controller.js dans le namespace défini #}
<div class="UserListGrid" {{ attributes.defaults(stimulus_controller('appear') }}>
{# cette étape permet de garder en mémoire les résultats précédents #}
{# data-live-ignore true empèche le chargement de nouvelles données car fait dans la boucle #}
{% if page > 1 %}
<div class="UserListGrid_page" id="items--{{ page - 1 }}-{{ per_page }}"
data-live-ignore="true"></div>
{% endif %}
{# page courrante #}
{% for item in this.items %}
<div class="UserListGrid_page" id="items--{{ page }}-{{ loop.index }}">
{{ component('ShowUser', { user: item }) }}
</div>
{% endfor %}
...Certaines parties vont de ce fait changer pour le "this.hasMore"
{# va charger le fichier appears-controller.js dans le namespace défini #}
<div class="UserListGrid" {{ attributes.defaults(stimulus_controller('appear') }}>
...
{% if this.hasMore %}
<div
class="nice-loading"
{# charge la target coté appear-controller #}
data-appear-target="loader"
{# passe par appear controller au déclenchement de l'event LiveAction #}
data-action="appear->live#action"
{# attends 750ms et appelle la méthode more coté PHP #}
data-live-action-param="debounce(750)|more"
>
</div>
{% endif %}import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['loader'];
loaderTargetConnected(element) {
this.observer ??= new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.dispatchEvent(new CustomEvent('appear', {detail: {entry}}));
}
});
});
this.observer?.observe(element);
}
loaderTargetDisconnected(element) {
this.observer?.unobserve(element);
}
}Le Javascript ressemblera à cela (pris de la démo Symfony UX)
Avec cette implémentation, nous avons une page en scroll infini automatique
D'autres possibilités avec les LiveComponents et autres implémentations
<div {{ attributes.defaults(stimulus_controller('video-modal')) }} >
<div class="modal fade"
tabindex="-1"
aria-hidden="true"
id="modal-content"
data-action="hidden.bs.modal->video-modal#stopVideo
shown.bs.modal->video-modal#reloadVideo"
>
...
{# implémentation lecteur vidéo #}
...
<button type="button" class="btn-close"
data-bs-dismiss="modal"
data-action="click->video-modal#stopVideo"
aria-label="Close">
</button>
</div>
</div>
Il est tout a fait possible d'appeler des fonctions stimulus spécifiques
Exemple: modal Bootstrap qui charge un lecteur vidéo
import { Controller } from '@hotwired/stimulus';
import { Modal } from 'bootstrap';
export default class extends Controller {
static targets = ["video"];
connect() {
console.log("video modal Connected");
}
disconnect() {
console.log("video modal Disconnected");
}
stopVideo() {
this.videoTargets.forEach(video => {
video.pause();
video.currentTime = 0;
video.removeAttribute('src');
console.log("Stop de la vidéo :", video);
})
}
reloadVideo() {
this.videoTargets.forEach(video => {
console.log("♻️ Rechargement de la vidéo :", video);
video.load();
});
}
}Appelant video_modal_controller.js
Déclencher un LiveListener
<?php
...
class ComponentOne
{
#[LiveAction]
public function launchSomething(#[LiveArg] int $id)
{
$this->emit('OpenSpecificModal', [
'id' => $id,
]);
}
}Et écouter dans un autre component
<?php
...
class ComponentTwo extends AbstractController
{
use DefaultActionTrait;
use ComponentToolsTrait;
#[LiveProp]
public string $html = '';
#[LiveListener('OpenSpecificModal')]
public function openModal(#[LiveArg] int $id): void
{
$this->html = $this->renderBlock(
'@app/components/OpenSpecificModal.html.twig',
'modal_body',
['id' => $id]
)->getContent();
}
}Traiter des formulaires
<?php
...
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
#[AsLiveComponent]
class ContactUsForm extends AbstractController
{
use DefaultActionTrait;
use ComponentWithFormTrait;
#[LiveProp]
public $initialFormData = null;
protected function instantiateForm(): FormInterface
{
return $this->createForm(ContactUsType::class, $this->initialFormData);
}
#[LiveAction]
public function send()
{
$this->submitForm();
$contactData = $this->getForm()->getData();
// Do something with $contactData
return $this->redirectToRoute(...);
}Et l'implémenter en Twig
<div {{ attributes }} class="...">
{# live#action:prevent même chose que live#action,
mais avec un preventDefault() intégré #}
{{ form_start(form, {
attr: {
'data-action': 'live#action:prevent',
'data-live-action-param': 'send'
}
}) }}
{{ form_widget(form.email) }}
{{ form_widget(form.message) }}
<button data-loading="addAttribute(disabled)">Envoyer</button>
{{ form_end(form) }}
</div>Attention !
- Les formulaires de ce type ne peuvent pas traiter de pièce jointes
- Il faut passer par des LiveAction dédié à cela pour récupérer des fichiers uploadés (voir les démos de la documentation officielle)
Préparer un champ AutoComplete pour un formulaire :
<?php
...
use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField;
use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType;
#[AsEntityAutocompleteField]
class UserAutocompleteField extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'class' => User::class,
'searchable_fields' => ['firstname', 'lastname', 'email'],
'max_results' => 20,
'query_builder' => function (UserRepository $userRepository) {
return $userRepository->createQueryBuilder('user')
->where(...);
}
]);
}
public function getParent(): string
{
return BaseEntityAutocompleteType::class;
}
}Appel dans le FormType
<?php
...
class SomethingFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('user', UserAutocompleteField::class, [
'autocomplete' => true,
'multiple' => true,
'required' => false,
'max_results' => 20,
]
);
}
public function configureOptions(OptionsResolver $resolver): void
{...}
}Tout cela n’était qu’un aperçu !
Il existe encore beaucoup d’autres possibilités.
Allez voir la documentation et les démos associées.
Le code source des démos :
Et croyez moi, c'est très utile :)

Il ne vous reste plus qu'à vous laisser tenter !


Des questions ?
Découverte de Symfony UX et composants Twig
By Kevin JHappy
Découverte de Symfony UX et composants Twig
- 7