HardТеория6 min

Продвинутый Twig

Макросы, Twig Components, расширения Twig, кастомные фильтры и функции, auto-escaping и raw

Макросы (Macros)

Макрос -- аналог функции в Twig. Определяет переиспользуемый фрагмент шаблона с параметрами. Макросы не имеют доступа к текущему контексту шаблона.

{# templates/macros/_forms.html.twig #}

{% macro input(name, value, type, attrs) %}
    <input
        type="{{ type|default('text') }}"
        name="{{ name }}"
        value="{{ value|e('html_attr') }}"
        {% for key, val in attrs|default({}) %}
            {{ key }}="{{ val|e('html_attr') }}"
        {% endfor %}
    >
{% endmacro %}

{% macro select(name, options, selected) %}
    <select name="{{ name }}">
        {% for value, label in options %}
            <option value="{{ value }}" {{ value == selected ? 'selected' : '' }}>
                {{ label }}
            </option>
        {% endfor %}
    </select>
{% endmacro %}

{% macro textarea(name, value, rows) %}
    <textarea name="{{ name }}" rows="{{ rows|default(5) }}">{{ value }}</textarea>
{% endmacro %}

Использование макросов

{# Import macros from another file #}
{% import 'macros/_forms.html.twig' as forms %}

{{ forms.input('username', '', 'text', {class: 'form-control', placeholder: 'Enter username'}) }}
{{ forms.input('email', user.email, 'email', {required: 'required'}) }}
{{ forms.select('country', countries, user.country) }}
{{ forms.textarea('bio', user.bio, 8) }}

{# Import specific macros #}
{% from 'macros/_forms.html.twig' import input, select %}

{{ input('name', '', 'text') }}
{{ select('role', roles, 'user') }}

Макросы в том же файле

{# Define and use macro in the same template #}
{% macro badge(text, type) %}
    <span class="badge badge-{{ type|default('secondary') }}">{{ text }}</span>
{% endmacro %}

{# In Twig 3.x, use _self to reference macros in same file #}
{{ _self.badge('Active', 'success') }}
{{ _self.badge('Pending', 'warning') }}
{{ _self.badge('Blocked', 'danger') }}

Подвох экзамена: Макросы НЕ имеют доступа к переменным текущего шаблона. Все данные нужно передавать через аргументы. Если макросу нужна переменная из контекста, передайте её явно. Это сделано намеренно для изоляции.

Twig Components (Symfony UX)

Начиная с Symfony 6.3+, рекомендуется использовать Twig Components вместо макросов для сложных переиспользуемых элементов.

Анонимные компоненты (HTML-only)

{# templates/components/Alert.html.twig #}
{# Anonymous component -- no PHP class needed #}

{% props type = 'info', dismissible = false %}

<div class="alert alert-{{ type }}" role="alert">
    {% if dismissible %}
        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
    {% endif %}

    {% block content %}{% endblock %}
</div>
{# Usage #}
<twig:Alert type="danger" :dismissible="true">
    <strong>Error!</strong> Something went wrong.
</twig:Alert>

<twig:Alert type="success">
    Record saved successfully.
</twig:Alert>

Live Components с PHP-классом

<?php

declare(strict_types=1);

namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class ProductCard
{
    public string $name;
    public float $price;
    public string $currency = 'EUR';
    public bool $featured = false;

    public function getFormattedPrice(): string
    {
        return number_format($this->price, 2, ',', ' ') . ' ' . $this->currency;
    }

    public function getBadgeClass(): string
    {
        return $this->featured ? 'badge-featured' : 'badge-standard';
    }
}
{# templates/components/ProductCard.html.twig #}
<div class="product-card {{ this.featured ? 'featured' : '' }}">
    <h3>{{ this.name }}</h3>
    <span class="price">{{ this.formattedPrice }}</span>
    <span class="{{ this.badgeClass }}">
        {{ this.featured ? 'Featured' : 'Standard' }}
    </span>
    {% block actions %}{% endblock %}
</div>

{# Usage #}
<twig:ProductCard name="Symfony Book" :price="49.99" :featured="true">
    <twig:block name="actions">
        <button class="btn btn-primary">Add to Cart</button>
    </twig:block>
</twig:ProductCard>

Подвох экзамена: Атрибуты с : (двоеточие) передают PHP-значения, без двоеточия -- строки. featured="true" -- строка "true", а :featured="true" -- булево true.

Twig Extensions

Twig Extension -- PHP-класс для добавления кастомных фильтров, функций, тестов и операторов.

Создание Extension

<?php

declare(strict_types=1);

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;

final class AppExtension extends AbstractExtension
{
    public function getFilters(): array
    {
        return [
            new TwigFilter('price', $this->formatPrice(...)),
            new TwigFilter('md5', md5(...)),
            new TwigFilter('truncate_words', $this->truncateWords(...)),
        ];
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('icon', $this->renderIcon(...), [
                'is_safe' => ['html'],  // Output will NOT be escaped
            ]),
            new TwigFunction('setting', $this->getSetting(...)),
        ];
    }

    public function getTests(): array
    {
        return [
            new TwigTest('premium', $this->isPremium(...)),
        ];
    }

    public function formatPrice(float $amount, string $currency = 'EUR'): string
    {
        return number_format($amount, 2, ',', ' ') . ' ' . $currency;
    }

    public function truncateWords(string $text, int $limit = 30): string
    {
        $words = explode(' ', $text);

        if (count($words) <= $limit) {
            return $text;
        }

        return implode(' ', array_slice($words, 0, $limit)) . '...';
    }

    public function renderIcon(string $name, string $class = ''): string
    {
        return sprintf(
            '<svg class="icon %s"><use href="#icon-%s"></use></svg>',
            htmlspecialchars($class, ENT_QUOTES),
            htmlspecialchars($name, ENT_QUOTES),
        );
    }

    public function getSetting(string $key): mixed
    {
        // Retrieve application setting
        return match ($key) {
            'site_name' => 'My Application',
            'version' => '2.0',
            default => null,
        };
    }

    public function isPremium(mixed $user): bool
    {
        return $user instanceof User && $user->isPremium();
    }
}
{# Using custom filters #}
{{ product.price|price }}              {# 1 234,56 EUR #}
{{ product.price|price('USD') }}       {# 1 234,56 USD #}
{{ article.body|truncate_words(50) }}

{# Using custom functions #}
{{ icon('search', 'icon-sm') }}        {# renders SVG icon, NOT escaped #}
{{ setting('site_name') }}

{# Using custom tests #}
{% if user is premium %}
    <span class="premium-badge">Premium</span>
{% endif %}

Lazy-loaded Extensions (Runtime)

Для фильтров и функций с тяжёлыми зависимостями используйте Runtime Extension. Twig загрузит зависимости только при фактическом вызове.

<?php

declare(strict_types=1);

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

// Extension class -- lightweight, no dependencies
final class MarkdownExtension extends AbstractExtension
{
    public function getFilters(): array
    {
        return [
            // Reference the runtime class and method
            new TwigFilter('markdown', [MarkdownRuntime::class, 'convert'], [
                'is_safe' => ['html'],
            ]),
        ];
    }
}
<?php

declare(strict_types=1);

namespace App\Twig;

use League\CommonMark\ConverterInterface;
use Twig\Extension\RuntimeExtensionInterface;

// Runtime class -- holds heavy dependencies, loaded lazily
final class MarkdownRuntime implements RuntimeExtensionInterface
{
    public function __construct(
        private readonly ConverterInterface $converter,
    ) {
    }

    public function convert(string $content): string
    {
        return $this->converter->convert($content)->getContent();
    }
}

Подвох экзамена: AbstractExtension регистрируется при загрузке каждого шаблона. RuntimeExtensionInterface загружается lazy -- только при вызове фильтра/функции. Для production-приложений с тяжёлыми зависимостями ВСЕГДА используйте Runtime.

Auto-escaping и raw

Как работает auto-escaping

Twig по умолчанию экранирует ВСЕ выводимые переменные для защиты от XSS.

{% set html = '<script>alert("xss")</script>' %}

{# Auto-escaped output (safe) #}
{{ html }}
{# Output: &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; #}

{# RAW output (dangerous!) #}
{{ html|raw }}
{# Output: <script>alert("xss")</script> #}

Стратегии экранирования

{# Default: html escaping #}
{{ value }}                 {# html strategy #}
{{ value|e }}               {# explicit html escaping #}
{{ value|e('html') }}       {# same as above #}

{# JavaScript escaping #}
<script>var name = '{{ name|e('js') }}';</script>

{# CSS escaping #}
<style>.bg { background: {{ color|e('css') }}; }</style>

{# URL escaping #}
<a href="?q={{ query|e('url') }}">Search</a>

{# HTML attribute escaping #}
<input value="{{ value|e('html_attr') }}">

Контроль auto-escaping

{# Disable auto-escaping for a block #}
{% autoescape false %}
    {{ html_content }}     {# NOT escaped! Be careful #}
    {{ other_content }}
{% endautoescape %}

{# Enable specific escaping strategy #}
{% autoescape 'js' %}
    var data = '{{ value }}';  {# JS-escaped #}
{% endautoescape %}

is_safe в Extensions

<?php

declare(strict_types=1);

// Mark output as safe -- will NOT be auto-escaped
new TwigFilter('markdown', $this->convert(...), [
    'is_safe' => ['html'],
]);

// Output WILL be auto-escaped (default behavior)
new TwigFilter('format_name', $this->formatName(...));

// Safe for html AND js contexts
new TwigFunction('json_config', $this->jsonConfig(...), [
    'is_safe' => ['html', 'js'],
]);

Подвох экзамена: |raw отключает экранирование. Это опасно, если данные приходят от пользователя. is_safe в Extension говорит Twig, что вывод уже безопасен и не нуждается в экранировании. Используйте is_safe только когда вы контролируете формат вывода.

Глобальные переменные

<?php

declare(strict_types=1);

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\Extension\GlobalsInterface;

final class AppGlobalsExtension extends AbstractExtension implements GlobalsInterface
{
    public function __construct(
        private readonly string $appVersion,
        private readonly string $appEnvironment,
    ) {
    }

    public function getGlobals(): array
    {
        return [
            'app_version' => $this->appVersion,
            'app_env' => $this->appEnvironment,
        ];
    }
}
{# Available everywhere without passing from controller #}
<footer>v{{ app_version }} ({{ app_env }})</footer>

{# Built-in Symfony globals #}
{{ app.user.email }}           {# Current user #}
{{ app.request.locale }}       {# Current locale #}
{{ app.session.get('key') }}   {# Session value #}
{{ app.environment }}          {# kernel environment: dev, prod #}
{{ app.debug }}                {# Debug mode #}
{{ app.flashes('success') }}   {# Flash messages #}

Тег apply (бывший filter)

{# Apply filter to a block of content #}
{% apply upper %}
    This text will be UPPERCASED.
{% endapply %}

{% apply striptags|title %}
    <p>hello <b>world</b></p>
{% endapply %}
{# Output: Hello World #}

Подвох экзамена: В Twig 3.x тег filter переименован в apply. Тег filter всё ещё работает, но считается устаревшим.

Именованные аргументы

{# Positional arguments #}
{{ text|truncate(100, true, '...') }}

{# Named arguments -- clearer and order-independent #}
{{ text|truncate(length=100, preserve=true, separator='...') }}

{# Mix positional and named #}
{{ "now"|date(format='d/m/Y', timezone='Europe/Moscow') }}

{# Named arguments in functions #}
{{ path(name='app_product_show', parameters={id: 42}) }}

Оператор defined и null-checks

{# Check if variable exists in context #}
{% if foo is defined %}
    {{ foo }}
{% endif %}

{# Check nested property #}
{% if user is defined and user.address is defined %}
    {{ user.address.city }}
{% endif %}

{# Null-safe navigation (Twig 3.x) #}
{{ user.address?.city ?? 'No city' }}

Итоги

  • Макросы -- функции в Twig, НЕ имеют доступа к контексту
  • Twig Components (<twig:Name>) -- современная замена макросам
  • Extensions добавляют фильтры, функции, тесты; Runtime для lazy-loading
  • Auto-escaping включён по умолчанию; |raw отключает (опасно!)
  • is_safe помечает вывод Extension как безопасный
  • {% apply %} -- применение фильтра к блоку контента (замена {% filter %})
  • Глобальные переменные через GlobalsInterface; встроенный app в Symfony

Проверь себя

Какой тег в Twig 3.x заменил устаревший тег `{% filter %}`?

Зачем нужен `RuntimeExtensionInterface` в Twig?

В чём разница между `<twig:Alert type="danger">` и `<twig:Alert :type="dangerLevel">`?

Что означает опция `'is_safe' => ['html']` при определении TwigFilter?

Имеют ли макросы (macros) доступ к переменным текущего контекста шаблона?