Представления
Когда сервер отдаёт готовый HTML — админку, страницу для поисковика, письмо —
Winter рендерит его из обычных PHP-шаблонов через ResponseView. Есть макеты,
частичные шаблоны и несколько хелперов; данные приходят в шаблон переменными.
Что такое представления и зачем
Представление (view) — шаблон, из которого собирается HTML ответа.
Проблема. Собирать разметку в контроллере конкатенацией строк тяжело читать и легко сломать: HTML перемешивается с логикой, экранирование забывается, а общий каркас страницы приходится повторять в каждом методе — и расходиться он начинает с первой же правки.
Решение. Держите разметку в отдельных PHP-файлах, а из контроллера возвращайте
ResponseView с именем шаблона и данными. Каркас выносится в макет, повторяющиеся
куски — в частичные шаблоны.
Winter — в первую очередь про API
Если приложение отдаёт JSON, представления не нужны: там ResponseEntity, см.
Ответы. Этот раздел — про случай, когда HTML собирает
сервер.
Два способа отрендерить
Разница между ними — есть ли общий каркас страницы.
use Flytachi\Winter\Kernel\Http\Response\ResponseView;
// Один шаблон, как есть
return ResponseView::view('user/profile', ['user' => $user]);
// Шаблон, вложенный в макет
return ResponseView::render('layouts/main', 'user/profile', ['user' => $user]);У render() первым идёт макет, вторым — ресурс, то есть сама страница.
Порядок легко перепутать, а сообщение об ошибке будет про ненайденный файл, так что
запомните: снаружи внутрь.
| Что | Кто это | Где обычно лежит |
|---|---|---|
| Ресурс | Страница: содержимое, ради которого пришёл запрос | views/user/profile.php |
| Макет | Каркас: <html>, шапка, подвал, подключение стилей |
views/layouts/main.php |
| Частичный шаблон | Кусок, который встраивается в другие | views/partial/nav.php |
Технической разницы между ними нет — все три обычные .php-файлы под одним
корнем; различаются только ролью.
Типичный контроллер, отдающий страницы:
#[RequestMapping('cabinet')]
class PageController extends Controller
{
#[Autowired] private UserService $service;
#[GetMapping('profile')]
public function profile(): ResponseView
{
return ResponseView::render('layouts/main', 'user/profile', [
'title' => 'Профиль',
'user' => $this->service->current(),
]);
}
}Где лежат шаблоны
По умолчанию — в каталоге views внутри ресурсов проекта, то есть
resources/views. Настраивать ничего не нужно: имя user/profile превращается в
resources/views/user/profile.php, расширение дописывается само.
Сменить корень целиком можно один раз при запуске приложения:
ResponseView::setBasePath(__DIR__ . '/theme');Это нужно редко — например, когда тема шаблонов ставится отдельным пакетом.
Шаблоны не сканируются — и это правильно
Каталог resources/ исключён из скана классов намеренно: шаблон это PHP-файл, и
сканер, найдя в нём объявление класса, подключил бы его при старте — то есть
выполнил бы разметку. Держите шаблоны в resources/, а классы — вне его.
Данные шаблона
Все ключи переданного массива становятся в шаблоне переменными:
<h1><?= htmlspecialchars($user->name) ?></h1>
<p>Регистрация: <?= $user->createdAt->format('d.m.Y') ?></p>Те же данные видны и в макете, и в частичных шаблонах — передавать их дальше вручную не нужно.
Рядом с ними всегда есть $data — весь массив целиком. Пригождается, когда ключи
заранее неизвестны или их нужно передать дальше пачкой:
<?php foreach ($data as $key => $value): ?>
<li><?= htmlspecialchars($key) ?></li>
<?php endforeach; ?>Имя data занято
Ключ data до шаблона не дойдёт: переменная $data всегда означает весь массив.
Остальные имена свободны — в том числе path, content и title.
Экранирование на вас
Winter ничего не экранирует: шаблон — это обычный PHP, а <?= $var ?> выводит
значение как есть. Всё, что пришло от пользователя, оборачивайте в
htmlspecialchars().
Хелперы шаблонов
Внутри шаблонов доступны четыре глобальные функции.
| Функция | Что делает |
|---|---|
wrContent() |
Выводит отрендеренный ресурс — вызывается в макете |
wrImport('partial/nav') |
Подключает другой шаблон прямо здесь |
wrData('title') |
Значение по ключу; без аргумента — весь массив данных |
wrIsActiveLink('/cabinet') |
Возвращает CSS-класс, если это текущий адрес |
Макет целиком выглядит так:
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title><?= htmlspecialchars(wrData('title') ?? 'Кабинет') ?></title>
</head>
<body>
<?php wrImport('partial/nav'); ?>
<main>
<?php wrContent(); ?>
</main>
</body>
</html>wrContent() — то место, куда встанет страница. Без этого вызова макет отрендерится
пустым: ресурс собирается до макета, и вставить его больше некому.
wrData() и обычная переменная — одно и то же значение; функция удобна там, где
ключа может не быть, потому что вернёт null вместо ошибки о неопределённой
переменной.
Подсветка текущего пункта меню
wrIsActiveLink() сравнивает аргумент с путём текущего запроса и возвращает одну из
двух строк. Сравнение точное, но строка запроса не учитывается: на
/cabinet/orders?page=2 пункт /cabinet/orders останется подсвеченным.
<nav>
<a class="<?= wrIsActiveLink('/cabinet/profile') ?>" href="/cabinet/profile">Профиль</a>
<a class="<?= wrIsActiveLink('/cabinet/orders') ?>" href="/cabinet/orders">Заказы</a>
</nav>По умолчанию возвращается active при совпадении и пустая строка иначе; оба класса
переопределяются вторым и третьим аргументами. Первым можно передать массив
адресов — тогда пункт считается активным на любом из них:
wrIsActiveLink(['/cabinet/orders', '/cabinet/orders/archive'], 'is-current', 'is-muted')Код ответа и заголовки
ResponseView — такой же билдер, как остальные ответы: третьим аргументом идёт
HTTP-код, заголовки добавляются цепочкой.
use Flytachi\Winter\Base\HttpCode;
return ResponseView::view('errors/404', ['path' => $path], HttpCode::NOT_FOUND)
->header('Cache-Control', 'no-store');Ответ всегда уходит с Content-Type: text/html; charset=utf-8.
Дальше
- Ответы —
ResponseEntity, файлы и своиSendable - Контроллеры — что возвращает обработчик
- Обработка ошибок — HTML-страница ошибки
- Локализация — переводы для текста в шаблонах