almdev Технический блог

Tailwind без Node в ежедневном цикле
Отдельный бинарник вместо npm-зависимостей и почему CDN на проде — плохая идея.

Три способа подключить Tailwind к Symfony и Laravel: CDN, отдельный бинарник и плагин сборщика. Где живёт конфигурация Tailwind 4, как сканируются классы из PHP и что кешировать в образе.

Подкатегория: Сборка

Чтение
5 мин
Технологии / версии
Docker · AssetMapper · Tailwind · CSS
26 мая 2026 · 5 мин · 1 просмотр · Frontend
Компактный пресс собирает цветные нити стилей в единый лист
Сборка 05/2026

Ставить Node в PHP-контейнер ради одного CSS-файла — 180 мегабайт образа и целая экосистема зависимостей, которая живёт своей жизнью. Держать Node на хосте — получить классическое «у меня собирается, у тебя нет», потому что версии разъедутся через месяц.

Третий вариант я нашёл не сразу и с тех пор использую в большинстве проектов.

Три способа

CDN. Одна строка в шаблоне, ноль сборки. Работает мгновенно, идеален для прототипа.

Отдельный бинарник. Tailwind распространяется скомпилированным исполняемым файлом, которому не нужен Node вообще. Скачивается один раз, лежит рядом, запускается как обычная команда.

Плагин сборщика. Tailwind как часть Vite или Webpack. Естественный вариант, если бандлер уже есть.

Выбор между ними — не про удобство, а про то, что уже стоит в проекте.

Почему CDN не доживает до прода

Скрипт с CDN не собирает CSS заранее — он разбирает разметку в браузере и генерирует правила на лету. Это значит:

  • более 300 килобайт несжатого JavaScript при первой загрузке; повторные переходы обычно спасает HTTP-кеш;
  • вспышка нестилизованного содержимого, пока скрипт не отработал;
  • никакого удаления неиспользуемого;
  • внешняя зависимость при первом открытии и после очистки кеша.

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

У меня один проект до сих пор живёт на CDN, и я знаю почему: там Tailwind подключался «на неделю посмотреть», а потом стало некогда. Стоит это примерно 200 мс на первую отрисовку и один пункт в списке технического долга, который я всё не закрою.

Бинарник через bundle

Для Symfony есть пакет, который прячет всю возню:

composer require symfonycasts/tailwind-bundle
php bin/console tailwind:init

Первая же команда сборки скачивает нужный бинарник под текущую платформу в var/tailwind/ и запускает его. Node не участвует нигде.

php bin/console tailwind:build --watch     # разработка
php bin/console tailwind:build --minify    # прод

Файл .gitignore пополняется одной строкой — сам бинарник в репозиторий не едет, он скачивается при первой сборке. Версия закреплена в конфигурации пакета, так что у всех она одна.

Для Laravel эквивалента-пакета нет. Одной строки в composer.json недостаточно: бинарник ещё нужно скачать под нужную архитектуру, закрепить версию и проверить SHA-256. Надёжнее делать это отдельным установочным скриптом или на этапе сборки образа. После такого provisioning команда остаётся простой:

./bin/tailwindcss -i resources/css/app.css -o public/build/app.css --minify

Версию и контрольные суммы надо хранить рядом с установочным скриптом. Универсальных значений здесь нет: они меняются при осознанном обновлении бинарника, а не вслед за очередным запуском сборки.

Где живёт конфигурация в четвёртой версии

Tailwind 4 перенёс настройку из JS-файла в сам CSS. Это меняет ощущение работы сильнее, чем звучит.

@import "tailwindcss";

@theme {
    --color-surface: #fbfbfc;
    --color-ink: #141920;
    --color-accent: #0f6b62;

    --font-display: "IBM Plex Sans Condensed", system-ui, sans-serif;
    --spacing-gutter: 1.5rem;
}

@source "../views/**/*.blade.php";
@source "../../app/Filament/**/*.php";

Токены объявляются как переменные CSS и сразу доступны и как утилиты (bg-surface), и как обычные переменные в своих правилах (var(--color-surface)). Раньше для второго приходилось дублировать значения.

В Tailwind 4 основной заменой массива content стало автоматическое обнаружение файлов от рабочей директории. Директива @source явно добавляет нужные пути — например, когда команда запускается не из корня, каталог исключён правилами поиска или хочется ограничить набор источников.

Классы, которых сканер не видит

Главная ловушка Tailwind в любом PHP-проекте. Сканер ищет строки, похожие на классы, простым поиском по файлам. Он не выполняет код.

Не сработает:

$color = $isActive ? 'green' : 'gray';
$class = "bg-{$color}-100";   // такого класса в выдаче не будет

Сработает:

$class = $isActive ? 'bg-green-100' : 'bg-gray-100';

Правило: полное имя класса должно физически присутствовать в файле как строка. Собирать имена конкатенацией нельзя.

Отдельная боль — классы, которые приходят из базы. У меня в блоге тема раздела хранится в поле theme_class со значениями вроде t-backend. Сканер их не найдёт никогда, потому что в коде их нет. Такие классы описаны обычным CSS в файле темы, а не утилитами.

И третье место, о котором забывают в Laravel: классы внутри PHP-классов Filament или компонентов. Tailwind 4 обычно найдёт отслеживаемые файлы в app/ автоматически. Но если сборка стартует из другой директории, автоматический поиск отключён через source(none) или каталог исключён, путь надо добавить явным @source. Я оставляю явную строку для app/Filament, чтобы это требование было видно в самом CSS.

Сборка в образе

В разработке — режим наблюдения, запущенный внутри контейнера:

php bin/console tailwind:build --watch

В Tailwind 4 у SymfonyCasts TailwindBundle нет флага --poll. Если события файловой системы через Docker Desktop не доходят, этой опцией проблему уже не обойти: watcher приходится запускать вне проблемного bind mount либо менять настройки синхронизации файлов.

В образе для прода CSS собирается на этапе сборки, а не при запуске. Ниже только фрагмент Dockerfile: базовый stage уже содержит приложение, vendor/ и конфигурацию Symfony.

COPY assets/ ./assets/
COPY templates/ ./templates/
RUN php bin/console tailwind:build --minify

Порядок строк важен для кеширования слоёв: сначала копируются каталоги, от которых зависит CSS. Если классы встречаются только в assets/ и templates/, правка контроллера не сбросит этот слой. Когда PHP-классы тоже входят в @source, соответствующий каталог нужно скопировать до сборки, и его изменения будут честно пересобирать стили.

Готовый файл — 34 килобайта до сжатия, 9 после. Это на проекте с сотней шаблонов.

Когда всё-таки нужен плагин сборщика

Если Vite в проекте уже есть, отдельный бинарник — лишняя сущность. Плагин делает то же самое, но встраивается в общий цикл: горячая замена стилей работает без перезагрузки страницы, а это заметно приятнее, чем перезагрузка после каждой правки класса. Он же естественнее, когда проекту нужны npm- или PostCSS-плагины, которых нет в standalone-сборке.

Разница в ощущениях: с бинарником в режиме наблюдения после правки надо обновить вкладку, и это примерно секунда. С плагином стили меняются на месте, состояние страницы сохраняется. На вёрстке модального окна это разница между «удобно» и «мучение».

Так что моё правило такое: есть бандлер — берём плагин. Нет бандлера — берём бинарник и не заводим бандлер только ради CSS.

Итог

Отдельный бинарник закрыл для меня вопрос Node в PHP-контейнере полностью. Образ похудел, npm-зависимостей не прибавилось, а сам бинарник стал отдельной закреплённой зависимостью. Сборка идёт одной консольной командой, одинаковой на всех проектах.

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

#docker #assetmapper #tailwind #css