Twig, le moteur de template intégré à Symfony, présente de nombreux avantages et 2 points noirs : l'analyse statique et l’auto-complétion de code.
Addicte à la DX que procure les outils modernes tel que PHPStan, Psalm et PHP8... me voici en quête de ces mêmes outils sur le moteur de thème que j'utilise le plus.
La place est libre ! Aucune solution en vue côté Symfony.
PHPStorm permet de bénéficier l'autocomplétation que ce soit sur Twig ou Blade à condition de définir les type en commentaire. Côté Blade (Laravel), l'analyse statique commence (2023) à arriver via un plugin phpstan.
Le constat tombe : pour retrouver au cœur des vues PHPStan ou Psalm réactif et l’auto-complétion du code sans avoir à développer et maintenir un plugin... il semble opportun de basculer sur... PHP.
League/Plates semblent être la référence dans la catégorie moteur de thème PHP bien que le projet n'est plus activement maintenu. C'est vers celui-ci que je me suis initialement tourné.
Cet article vous présente donc un voyage de Twig à Leagues/Plates pour avoir un environnement de développement plus efficace.
23 décembre 2024 : Cet article est partiellement valide. J'utilise maintenant un fork de League/Plates qui donne le smile ! Les fichiers Template peuvent s'écrire dans une class, celle-ci est partiellement réécrites (via rector) pour injecter les outils fournis par Plates tout en permettant l'autowiring, la complétion du code et l'analyse statique. Le fichier réécrit est le même que le fichier, il ne s'agit pas d'une mise en cache. J'ai donc migrer un projet en 2 temps : d'abord vers Plates, ensuite vers ce fork. Cette seconde migration n'est pas documentée tout comme ce fork. Il est par contre 100% rétro-compatible avec la librairie originale ce qui a permit une migration douce. Voici un exemple de template. Une poule riqu'ouest a été suggérée, je doute toutefois qu'elle soit validée un jour. Je m'éloigne petit à petit de la librairie initiale amenant des BC dans le développement du fork.
composer req league/plates
Créer d'un service pour rendre disponible Plates dans l'application Symfony :
<?php
namespace PiedWeb\SeoStatus\Service;
use League\Plates\Engine;
class PlatesTemplateEngine extends Engine
{
public function __construct(
PlatesExtension $plateExtension
) {
parent::__construct(__DIR__.'/../templates', '');
$this->setFileExtension(null);
$this->loadExtensions([
$plateExtension,
// ...
]);
}
}
Créer PlatesExtension
précédemment cité pour charger quelques fonctions supplémentaires qui pourront être utiliser dans les templates :
<?php
namespace PiedWeb\SeoStatus\Service;
use Exception;
use League\Plates\Engine;
use League\Plates\Extension\ExtensionInterface;
use League\Plates\Template\Template;
use ReflectionClass;
use ReflectionMethod;
use function Safe\ob_start;
use Symfony\Bridge\Twig\AppVariable;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\String\UnicodeString;
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
use Twig\Environment as TwigEnvironment;
class PlatesExtension implements ExtensionInterface
{
public Template $template;
public function __construct(
private readonly EntrypointLookupInterface $entrypointLookup,
private readonly AppVariable $twigAppVariable,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly TwigEnvironment $twig,
) {
}
private PlatesTemplateEngine $engine;
public function register(Engine $engine): void
{
$this->engine = $engine instanceof PlatesTemplateEngine ? $engine : throw new Exception();
$reflectionClass = new ReflectionClass($this);
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
if (\in_array($method->getName(), ['__construct', 'register'])) {
continue;
}
$this->engine->registerFunction($method->getName(), [$this, $method->getName()]); // @phpstan-ignore-line
}
}
public function app(): AppVariable
{
return $this->twigAppVariable;
}
public function getRequest(): Request
{
return $this->app()->getRequest() ?? throw new Exception();
}
/**
* @return string[]
*/
public function getWebpackCssFiles(string $entryName): array
{
return $this->entrypointLookup->getCssFiles($entryName);
}
/**
* @return string[]
*/
public function getWebpackJsFiles(string $entryName): array
{
return $this->entrypointLookup->getJavaScriptFiles($entryName);
}
/**
* @param array<mixed> $parameters
*/
public function url(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
{
return $this->urlGenerator->generate($name, $parameters, $referenceType);
}
public function u(string|int|float $string): UnicodeString
{
return new UnicodeString((string) $string);
}
public function form(FormInterface $form): string
{
$form = $form->createView();
return $this->twig->render('twigForm/form.html.twig', ['form' => $form]);
}
}
Créer PlatesTemplates dont l'usage sera détaillé après lors de la migration du code Twig :
<?php
namespace PiedWeb\SeoStatus\Service;
use League\Plates\Template\Template;
class PlatesTemplate extends Template
{
}
Note : Voici un scriptpour gérer sa mise à jour de façon automatique. Il permettra d'ajouter les fonctions chargées depuis les extensions en générant un docblock.
SupprimerUse Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
dans les controllers afin de créer un AbstractController
au niveau de l'app qui permettra d'utiliser Plates sans modifier le code :
<?php
namespace My\App\Controller;
use My\App\Service\PlatesTemplateEngine;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as SfAbstractController;
use Symfony\Component\Form\FormInterface;
abstract class AbstractController extends SfAbstractController
{
/**
* @param array<mixed> $parameters
*/
protected function renderView(string $view, array $parameters = []): string
{
if (str_ends_with($view, 'php')) {
return parent::renderView($view, $parameters);
}
$this->container->get(PlatesTemplateEngine::class)->renderViewForController($view, $parameters);
}
}
find . -type f -name "*.twig" -exec rename 's/\.twig$/.php/' {} \;
$this
Générer PlatesTemplates.php pour avoir une class
documentée qui permettra l’auto-complétion en prenant en compte les fonctions ajoutées via les extensions comme les fonctions disponibles nativement.
Au début de chaque template, insérer :
<?php
use My\App\Service\PlatesTemplate;
/**
* @var PlatesTemplate $this
*/
?>
(en une ligne pour tous les fichiers)
Dans ce même docblock, détailler les variables requises. Exemple :
<?php
use My\App\Service\PlatesTemplate;
/**
* @var PlatesTemplate $this
* @var string $title
*/
?>
<?= $this->e($title) ?>
extends
vers layout
{% extends 'base.html.twig' %}
<?php $this->layout('test.html') ?>
Presque (trop simple) la fonction layout
n'est pas aussi puissante que la fonction extend
:
macro
côté twig, devenant en toute logique des function
côté php ne sont pas héritées (plusieurs alternatives détaillées ci-après)block
vers section
et start
Un block se définit ainsi :
{% block('example') %}
My block
{% endblock %}
<?= $this->section('example', 'My Block') ?>
On remarque déjà que le contenu par défaut est beaucoup moins personnalisable que via twig et qu'unesection
peut difficilement contenir unesection
enfant contrairement au block
twig.
Sa valeur se complète dans les fichiers utilisant layout avec start
et stop
<?php $this->start('example') ?>My Block<?php $this->stop() ?>
À noter : on perd la fonctionnalité parent()
Et c'est là que le bas blesse vis à vis de Twig, un thème qui en étend un autre qui en étend un autre via layout
verra chaque bloc
ouvert part start
rendu même si l'intégralité du contenu n'est pas conservé. Que ce soit en terme de performance ou de fiabilité du code... la solution n'était pas optimal.
Sans entraîner de BC break chez Plates, j'ai donc modifier la syntaxe pour arriver à :
<?php if ($this->start('example')) { ?>
My Block
<?php } $this->stop() ?>
include
vers fetch
ou insert
{% include 'example.html.twig' %}
<?= $this->fetch('example.html.php') ?>
<?php $this->insert('example.html.php') ?>
macro
3 alternatives semblent possibles :
templates/helper/example.php
et les intégrer via un require_once à l'ancienne. ⚠️ Prendre garde à se fixer un code de conduite pour ne pas finir comme une extension wordpress.insert
en plaçant l'ancienne macro dans un fichier dédié (exemple partial/tooltip.html.php
) → rapide à mettre en place, on ne gagne pas l'intérêt de la complétion du code ou de l'analyse statique préalablement recherché$this
→ plus lourd à mettre en place, on retrouve par contre la complétion du code et l'analyse statique. Point noir : la macro qui était avant défini pour un fichier devient disponible pour l'ensemble des fichiers. Pour cette dernière solution, j'ai proposé un script en début d'article pour que l'autocomplétion soit fonctionnelle.form
form_widget
Ici, on arrive sur un os.
J'ai pour l'instant contourner la migration du code en gérant les formulaires symfony directement avec twig.
Voir le code Symfony/Twig à faire évoluervers Symfony/Plates
defaut
, u
et compagnieQuelques exemples pour ces cas plutôt simple où PHP remplace de manière native les fonctions ou filtres de Twig :
{{ exampleVariableWichMayNotExist|default('default value') }}
<?= $exampleVariableWichMayNotExist ?? 'default value' ?>
{{ maybeAVeryLongString|u.truncate(50, '…') }}
<?php
use Symfony\Component\String\UnicodeString;
?>
<?= (new UnicodeString($maybeAVeryLongString)->truncate(50, '…') ?>
La documentation de Plates et plutôt complète concernant ce point.
Exemple pour transmettre les données du template en cours dans un fetch par exemple :
<?php
namespace MyAppService;
class PlatesAppExtension implements ExtensionInterface
{
public Template $template;
public function dataTablesLabel(string text, bool $textClickable = true): string
{
return $this->engine->render(
'partial/dataTablesLabel.html.php', // LINK src/templates/partial/dataTablesLabel.html.php
$this->getFuncArgsNamed(__FUNCTION__, unc_get_args()) + $this->template->data()
);
}
}
PHP-CS-Fixer
$config->setRules([
'echo_tag_syntax' => ['format' => 'short'],
'semicolon_after_instruction' => false,
]);
Et éventuellement ses paramètres VSCode(settings.json) pour une meilleure lisibilité :
{
"editor.tokenColorCustomizations": {
"textMateRules": [
{
"scope": [
"punctuation.definition.tag.begin.php",
"punctuation.section.embedded.begin.php",
"punctuation.section.embedded.end.php",
"punctuation.definition.tag.end.php"
],
"settings": {
"foreground": "#94a3b8"
}
}
]
},
}
Après plusieurs essais, pour activer la navigation depuis l'IDE vers un fichier de thème, j'ai opté pour l'utilisation d'un commentaire en m'appuyant sur le plugin VS Code File Link:
/** @see src/templates/index.html.php */
return $this->render('index.html.php', $parameters);
La migration est en cours sur une application de taille moyenne (22 fichiers twig et 2650 lignes de code à migrer).
J'ai pour l'instant passé 3 jours sur la migration. Elle arrive à son terme et j'attends quelques retours du gestionnaire actuel du projet plates pour pousser quelques PR ou switcher à un fork.
Le code présenté ci-dessus est un aperçu rapide de mes premiers essais. L'article sera édité et les exemples complétés avec une version plus complète implémentant des fonctionnalités similaires au Bridge entre Twig et Symfony(router, app, ...).
Je vous invite à vous abonner si vous souhaitez être notifier de la mise à jour de cet article.
En fonction de la fin de cette expérience, un package permettant de simplifier la première étape sera publiée sur packagist.
Vos avis et retours sont également appréciés via Twitter ou mail contactpiedweb.com sur ce projet !!