MidТеория8 min

Отладка и ассеты

Отладка Twig (dump(), profiler, Web Debug Toolbar), управление ассетами, AssetMapper, Webpack Encore, Twig Extensions, кастомные фильтры и функции

Отладка Twig и управление ассетами

Отладка шаблонов: dump()

Функция dump() -- основной инструмент отладки в Twig. Работает только в dev и test окружениях.

{# Dump all variables available in template #}
{{ dump() }}

{# Dump specific variable #}
{{ dump(user) }}

{# Dump multiple variables #}
{{ dump(user, order, cart) }}

{# Dump nested property #}
{{ dump(order.items) }}

{# Dump inside a loop #}
{% for item in order.items %}
    {{ dump(item) }}
{% endfor %}

dump() vs dd() vs dump() в PHP

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

final class DebugController extends AbstractController
{
    public function show(): Response
    {
        $data = ['key' => 'value'];

        // dump() -- outputs and continues execution
        dump($data);

        // dd() -- "dump and die", stops execution
        dd($data);

        // In Twig: {{ dump(variable) }} -- rendered as HTML in template
        return $this->render('debug/show.html.twig', [
            'data' => $data,
        ]);
    }
}

Подвох экзамена: dump() в Twig доступна ТОЛЬКО когда debug: true в конфигурации (обычно dev/test). В production dump() вызовет ошибку. Это обеспечивает безопасность -- чувствительные данные не утекут в production.

Тег dump (без вывода в шаблон)

{# dump as tag -- output goes to Web Debug Toolbar, not to page #}
{% dump user %}
{% dump order.items %}

{# This is cleaner -- doesn't pollute the rendered page #}
{# Data appears in Profiler's "Dump" panel #}

Web Debug Toolbar

Web Debug Toolbar (WDT) -- панель внизу страницы в dev-окружении. Показывает информацию о текущем запросе.

Панели WDT

Панель Информация
Performance Время выполнения, потребление памяти
Request Контроллер, маршрут, параметры, формат
Twig Количество шаблонов, время рендеринга, вызовы блоков
Doctrine SQL-запросы, количество, время
Security Текущий пользователь, роли, firewall
Forms Структура форм, данные, ошибки валидации
Translation Использованные переводы, пропущенные ключи
Cache Hits/misses, operations
Logger Логи текущего запроса
Dump Переменные из dump() и {% dump %}

Включение/отключение WDT

# config/packages/web_profiler.yaml
when@dev:
    web_profiler:
        toolbar: true          # Show toolbar
        intercept_redirects: false  # Don't intercept redirects

when@test:
    web_profiler:
        toolbar: false         # Disable in tests
        collect: false

Profiler

<?php

declare(strict_types=1);

// Profiler stores detailed data for each request
// Access at: /_profiler/ (in dev environment)

// You can link to the profiler in Twig:
// {{ render(controller('web_profiler.controller.profiler::toolbarAction')) }}
{# Profiler URL for current request #}
<a href="{{ path('_profiler', {token: app.request.attributes.get('_stopwatch_token')}) }}">
    Open Profiler
</a>

{# In Twig panel of Profiler, you can see: #}
{# - All rendered templates #}
{# - Template hierarchy (extends, includes) #}
{# - Block rendering times #}
{# - Template variables #}

Twig Profiler Panel

Profiler Twig panel показывает:

  • Все отрендеренные шаблоны (включая вложенные)
  • Иерархию наследования шаблонов
  • Время рендеринга каждого шаблона
  • Количество вызовов блоков
  • Использование include, embed, render
{# Stopwatch -- measure specific template sections #}
{% stopwatch 'heavy_section' %}
    {# This section will appear in Profiler Performance timeline #}
    {% for product in products %}
        {% include 'product/_card.html.twig' %}
    {% endfor %}
{% endstopwatch %}

Подвох экзамена: Тег {% stopwatch %} работает только при наличии компонента Stopwatch. Он добавляет секции в timeline Profiler. В production этот тег безопасно игнорируется.

Управление ассетами: AssetMapper

AssetMapper -- современный подход к управлению ассетами в Symfony (начиная с 6.3). Не требует Node.js, npm или сборщиков.

Установка и конфигурация

composer require symfony/asset-mapper
# config/packages/asset_mapper.yaml
framework:
    asset_mapper:
        paths:
            - assets/
        missing_import_mode: strict  # Error on missing imports

Структура ассетов

assets/
    styles/
        app.css
        _variables.css
    controllers/
        hello_controller.js
    app.js
    bootstrap.js

Использование в Twig

{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
    {# AssetMapper generates importmap and preloads #}
    {% block importmap %}{{ importmap('app') }}{% endblock %}
</head>
<body>
    {% block body %}{% endblock %}
</body>
</html>
// assets/app.js
import './styles/app.css';

// Import from vendor (installed via importmap:require)
import { Application } from '@hotwired/stimulus';
import { definitionsFromContext } from '@hotwired/stimulus-webpack-helpers';

const app = Application.start();

Команды AssetMapper

# Install a JavaScript package
php bin/console importmap:require bootstrap
php bin/console importmap:require lodash

# List mapped assets
php bin/console debug:asset-map

# Compile assets for production (versioned, fingerprinted)
php bin/console asset-map:compile

Функция asset() в Twig

{# Reference static files #}
<img src="{{ asset('images/logo.png') }}" alt="Logo">
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">

{# With versioned assets (cache busting) #}
{# Generates: /assets/images/logo-a1b2c3d4.png #}
<img src="{{ asset('images/logo.png') }}">
# config/packages/framework.yaml
framework:
    assets:
        version: '1.0.0'                  # Manual versioning
        # OR
        version_strategy: json_manifest    # Manifest-based versioning
        json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'

Подвох экзамена: asset() в Twig генерирует URL к статическим файлам. С version_strategy: json_manifest читает маппинг из manifest.json. AssetMapper автоматически добавляет content hash к именам файлов для cache busting. Без AssetMapper или Encore -- asset() просто возвращает путь как есть.

Webpack Encore (альтернатива AssetMapper)

Webpack Encore -- обёртка над Webpack для Symfony. Требует Node.js.

composer require symfony/webpack-encore-bundle
npm install
// webpack.config.js
const Encore = require('@symfony/webpack-encore');

Encore
    .setOutputPath('public/build/')
    .setPublicPath('/build')
    .addEntry('app', './assets/app.js')
    .addStyleEntry('app_styles', './assets/styles/app.css')
    .enableStimulusBridge('./assets/controllers.json')
    .splitEntryChunks()
    .enableSingleRuntimeChunk()
    .cleanupOutputBeforeBuild()
    .enableSourceMaps(!Encore.isProduction())
    .enableVersioning(Encore.isProduction())
    .enableSassLoader()      // Optional: SASS support
    .enableTypeScriptLoader() // Optional: TypeScript support
;

module.exports = Encore.getWebpackConfig();

Encore в Twig

{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
    {% block stylesheets %}
        {{ encore_entry_link_tags('app') }}
    {% endblock %}
</head>
<body>
    {% block body %}{% endblock %}

    {% block javascripts %}
        {{ encore_entry_script_tags('app') }}
    {% endblock %}
</body>
</html>

AssetMapper vs Webpack Encore

Аспект AssetMapper Webpack Encore
Node.js Не нужен Нужен
Сборка Нет (native ESM) Webpack
SASS/TypeScript Нет (используйте нативные CSS/JS) Да
Tree shaking Браузер (HTTP/2) Webpack
Hot reload Нет Да
Production asset-map:compile npm run build
Рекомендация Новые проекты Symfony 7+ Legacy или сложные сборки

Создание Twig Extension

Кастомный фильтр

<?php

declare(strict_types=1);

namespace App\Twig;

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

final class AppExtension extends AbstractExtension
{
    public function getFilters(): array
    {
        return [
            // Simple filter: value is first argument
            new TwigFilter('price', $this->formatPrice(...)),

            // Filter with environment access
            new TwigFilter('highlight', $this->highlight(...), [
                'is_safe' => ['html'], // Output is not auto-escaped
            ]),

            // Filter with pre_escape (escape input BEFORE filter)
            new TwigFilter('nl2br_safe', $this->nl2br(...), [
                'pre_escape' => 'html',
                'is_safe' => ['html'],
            ]),
        ];
    }

    public function getFunctions(): array
    {
        return [
            // Function with HTML-safe output
            new TwigFunction('svg_icon', $this->renderSvgIcon(...), [
                'is_safe' => ['html'],
            ]),

            // Function that needs Twig Environment
            new TwigFunction('render_widget', $this->renderWidget(...), [
                'needs_environment' => true,
            ]),

            // Function that needs template context
            new TwigFunction('debug_context', $this->debugContext(...), [
                'needs_context' => true,
            ]),
        ];
    }

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

    public function highlight(string $text, string $query): string
    {
        // Safely highlight search term in text
        $escaped = htmlspecialchars($query, ENT_QUOTES, 'UTF-8');

        return str_ireplace(
            $escaped,
            '<mark>' . $escaped . '</mark>',
            htmlspecialchars($text, ENT_QUOTES, 'UTF-8'),
        );
    }

    public function nl2br(string $text): string
    {
        // Input is already escaped (pre_escape), just convert newlines
        return nl2br($text);
    }

    public function renderSvgIcon(string $name, int $size = 24): string
    {
        return sprintf(
            '<svg width="%d" height="%d"><use href="/icons.svg#%s"/></svg>',
            $size,
            $size,
            htmlspecialchars($name, ENT_QUOTES),
        );
    }

    public function renderWidget(
        \Twig\Environment $env,
        string $widget,
    ): string {
        // Access Twig environment for advanced operations
        return $env->render('widgets/' . $widget . '.html.twig');
    }

    public function debugContext(array $context): string
    {
        // Access all template variables
        return implode(', ', array_keys($context));
    }
}
{# Using custom filters and functions #}
{{ product.price|price }}              {# "1 234,56 RUB" #}
{{ product.price|price('USD') }}       {# "1 234,56 USD" #}

{{ article.body|highlight(search_query) }}
{{ "Line 1\nLine 2"|nl2br_safe }}

{{ svg_icon('search', 20) }}
{{ render_widget('recent_posts') }}
{{ debug_context() }}

Lazy Runtime Extension

<?php

declare(strict_types=1);

namespace App\Twig;

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

// Extension -- lightweight, no dependencies
final class SlugExtension extends AbstractExtension
{
    public function getFilters(): array
    {
        return [
            new TwigFilter('slugify', [SlugRuntime::class, 'slugify']),
        ];
    }
}
<?php

declare(strict_types=1);

namespace App\Twig;

use Symfony\Component\String\Slugger\SluggerInterface;
use Twig\Extension\RuntimeExtensionInterface;

// Runtime -- heavy dependencies, loaded only when filter is used
final class SlugRuntime implements RuntimeExtensionInterface
{
    public function __construct(
        private readonly SluggerInterface $slugger,
    ) {}

    public function slugify(string $text): string
    {
        return $this->slugger->slug($text)->lower()->toString();
    }
}

Подвох экзамена: Extension регистрируется при каждом рендере шаблона. Runtime загружается ТОЛЬКО при вызове фильтра/функции. Для Extension с зависимостями (сервисы, репозитории) ВСЕГДА используйте Runtime паттерн для оптимальной производительности.

Кастомный тест (Twig Test)

<?php

declare(strict_types=1);

namespace App\Twig;

use App\Entity\User;
use Twig\Extension\AbstractExtension;
use Twig\TwigTest;

final class UserTestExtension extends AbstractExtension
{
    public function getTests(): array
    {
        return [
            new TwigTest('admin', $this->isAdmin(...)),
            new TwigTest('active', $this->isActive(...)),
        ];
    }

    private function isAdmin(mixed $user): bool
    {
        return $user instanceof User && in_array('ROLE_ADMIN', $user->getRoles(), true);
    }

    private function isActive(mixed $user): bool
    {
        return $user instanceof User && $user->isActive();
    }
}
{# Using custom tests #}
{% if user is admin %}
    <a href="{{ path('admin_dashboard') }}">Admin Panel</a>
{% endif %}

{% if user is not active %}
    <span class="badge-inactive">Account disabled</span>
{% endif %}

Отладка через консольные команды

# List all Twig filters, functions, tests, globals
php bin/console debug:twig

# Show details about specific filter/function
php bin/console debug:twig --filter=price
php bin/console debug:twig --function=asset

# List all registered Twig paths (namespaces)
php bin/console debug:twig --paths

# Show template for form theme
php bin/console debug:twig --theme

Lint шаблонов

# Check all templates for syntax errors
php bin/console lint:twig templates/

# Check specific file
php bin/console lint:twig templates/base.html.twig

# Check from stdin
echo '{{ foo }' | php bin/console lint:twig -

Именованные пространства шаблонов

# config/packages/twig.yaml
twig:
    paths:
        '%kernel.project_dir%/templates': ''            # Default namespace
        '%kernel.project_dir%/templates/emails': email   # @email namespace
        '%kernel.project_dir%/src/Admin/templates': admin # @admin namespace
{# Using namespaced templates #}
{% extends '@admin/layout.html.twig' %}
{% include '@email/order_confirmation.html.twig' %}

{# Bundle templates use bundle name as namespace #}
{% extends '@WebProfiler/Profiler/layout.html.twig' %}

Итоги

  • dump() -- основной инструмент отладки; работает только при debug: true
  • {% dump var %} -- вывод в Profiler, не в страницу
  • {% stopwatch %} -- измерение производительности секций шаблона
  • Web Debug Toolbar: request, Twig, Doctrine, forms, translations, security
  • AssetMapper: без Node.js, native ESM, {{ importmap('app') }}
  • Webpack Encore: с Node.js, SASS/TS, {{ encore_entry_link_tags('app') }}
  • asset(): путь к статическому файлу с поддержкой версионирования
  • Extensions: getFilters(), getFunctions(), getTests()
  • Runtime Extension: lazy-загрузка зависимостей через RuntimeExtensionInterface
  • needs_environment, needs_context, is_safe, pre_escape -- опции фильтров/функций

Проверь себя

Что делает опция `needs_environment: true` при создании TwigFunction?

В чём разница между `{{ dump(var) }}` и `{% dump var %}` в Twig?

Зачем нужна опция `pre_escape` при определении TwigFilter?

Какая функция Twig используется для подключения ассетов через AssetMapper?

Что произойдёт при использовании `{{ dump(user) }}` в production-окружении?