Макросы (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: <script>alert("xss")</script> #}
{# 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