Это короткая заметка о том, как можно организовать использование скинов для брендирования страниц в Twig на примере Symfony. Это решение не привязано к Symfony. По аналогии можно реализовать скины в любом проекте, использующем Twig.


У вас интернет-магазин, онлайн-кинотеатр, афиша мероприятий, каталог телепередач и т.д. В один прекрасный день вам поступает задача по брендированию страницы каталога для привлечения пользователей и повышения продаж под какую-то акцию. Как это сделать, если для движка все продукты в каталоге равнозначны?


Самое простое решение — это захардкодить ID продукта из каталога. Можно добавить условие в шаблон и накладывать на body тег дополнительный CSS класс, по которому потом стилизовать страницу в общих стилях.


{% block body_class -%}
    {{ parent () }} product-{{ product.id }}
{%- endblock %}

body.product-12345 {
   # custom style
}

Стилями можно сделать очень многое, особенно если вы используете flex, но стили не всесильны. Иногда возможностей стилей недостаточно для брендирования страницы и необходимо изменить HTML-разметку (вёрстку) страницы, и делается это по аналогии со стилями.


{% if product.id == 12345 %}
    {# custom code #}
{% else %}
    {# original code #}
{% endif %}

Решение, конечно, некрасивое, но для единичного случая вполне приемлемо (YAGNI и KISS). Сказано — сделано. Закоммитил, запушил, забыл.


Проходит неделя, две, месяц — и к вам снова прилетает задача по брендированию уже другого товара. Вы делаете по аналогии с прошлым решением, коммитите и забываете. Потом прилетает ещё задача и ещё. Потом до менеджеров продаж или руководства доходит, что брендирование можно продавать за хорошие деньги. Вы и оглянуться не успели, как уже завалены всевозможными задачами по брендированию и правкам оформления. Становится очевидно, что надо что-то менять (DRY).


Лучшим решением в этом случае будет добавить дополнительное поле в сущность с названием скина. Кому как не сущности лучше знать, забрендирована она или нет, и если забрендирована, то как. Добавив поле в сущность, мы убираем всю лишнюю логику из шаблонов и стилей, и остаётся только вопрос организации стилизации по полю скина из сущности.


Рассмотрим страницу продукта с шаблоном product/show.html.twig. Заведем новую структуру папок product/skin/<skin_name>/, где <skin_name> — это значение поля скин у сущности. В качестве скина по умолчанию возьмём default и переместим наш шаблон страницы продукта по соответствующему адресу product/skin/default/show.html.twig. Теперь осталось только поправить контроллер, и можно пользоваться.


public function show(Product $product): Response
{
    return $this->render(sprintf('product/skin/%s/show.html.twig', $product->skin), [
        'product' => $product,
    ]);
}

Используйте наследование, чтобы не переписывать с нуля каждый раз весь шаблон просмотра продукта, когда нужно поменять только один блок.


{# product/skin/custom_skin/show.html.twig #}

{% extends 'product/skin/default/show.html.twig' %}

{% blocksome_block %}
    {{ parent() }}
    {# customise something #}
{% endblock %}

Кроме страницы просмотра самого товара, бывают другие с ней связанные. Например, страница отзывов, вопросы и ответы, хронология для фильмов и сериалов, актерский состав и т. д. На них часто есть блоки, общие для всех страниц продукта. В таких случаях обычно создают общий лейаут, который тоже можно загружать из скинов.


{# product/skin/default/show.html.twig #}

{% extends 'product/skin/' ~ product.skin ~ '/layout.html.twig' %}

{# ... #}

{# product/skin/custom_skin/layout.html.twig #}

{% extends 'product/skin/default/layout.html.twig' %}

{# ... #}

В результате мы получаем такую схема расширения:


  • product/skin/<skin_name>/show.html.twig
  • product/skin/default/show.html.twig
  • product/skin/<skin_name>/layout.html.twig
  • product/skin/default/layout.html.twig
  • ...

У такого подхода есть один недостаток — вам нужно повторять структуру файлов из скина по умолчанию в каждом новом скине.


  • default
    • layout.html.twig
    • show.html.twig
    • qa.html.twig
    • similar.html.twig
  • first_skin
    • layout.html.twig
    • show.html.twig
    • qa.html.twig
    • similar.html.twig
  • second_skin
    • layout.html.twig
    • show.html.twig
    • qa.html.twig
    • similar.html.twig

Особенно это неприятно, если нужно переопределить только пару строчек в лейауте. Решить эту проблему можно с помощью функций Twig.


public function show(Product $product, Twig $twig): Response
{
    $template = $twig->resolveTemplate([
        sprintf('product/skin/%s/show.html.twig', $product->skin),
        'product/skin/default/show.html.twig',
    ]);
    $content = $template->render([
        'product' => $product,
    ]);

    return new Response($content);
}

Метод resolveTemplate() принимает список шаблонов и поочередно пытается их резолвить. Таким образом будет рендерится шаблон страницы товара из скина, если он есть, а если нет — то шаблон по умолчанию. Код получился громоздкий, но ребята из Symfony не хотят его сокращать, поэтому придется писать так или создавать расширение в своем проекте.


Шаблон product/skin/default/show.html.twig расширяет лейаут скина, и мы вынуждены в каждом скине создать как минимум шаблон layout.html.twig. Решить эту проблему можно, передав Twig тегу extends список шаблонов, и тогда extends будет внутри вызывать resolveTemplate() и расширять только существующий шаблон. Это вообще избавит нас от необходимости создавать какой-либо шаблон в папке со скином.


{# product/skin/default/show.html.twig #}

{% extends [
    'product/skin/' ~ product.skin ~ '/layout.html.twig',
    'product/skin/default/layout.html.twig',
] %}

{# ... #}

Ещё хорошим решением будет вынести стили конкретных скинов в отдельные файлы и подключать только на брендированной странице. Так мы не будем засорять основные стили мусором, который применяется всего на паре страниц. Если кому интересно, то могу в отдельной статье рассказать, как настроить gulp для сборки большого количества скинов вместе и по отдельности. У нас на проекте таких скинов более 300 штук.