Основы веб-разработки

Представления

Когда сервер отдаёт готовый HTML — админку, страницу для поисковика, письмо — Winter рендерит его из обычных PHP-шаблонов через ResponseView. Есть макеты, частичные шаблоны и несколько хелперов; данные приходят в шаблон переменными.

Тип ответа ResponseViewШаблоны resources/viewsContent-Type text/html

Что такое представления и зачем

Представление (view) — шаблон, из которого собирается HTML ответа.

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

Решение. Держите разметку в отдельных PHP-файлах, а из контроллера возвращайте ResponseView с именем шаблона и данными. Каркас выносится в макет, повторяющиеся куски — в частичные шаблоны.

Winter — в первую очередь про API

Если приложение отдаёт JSON, представления не нужны: там ResponseEntity, см. Ответы. Этот раздел — про случай, когда HTML собирает сервер.

Два способа отрендерить

Разница между ними — есть ли общий каркас страницы.

php
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-файлы под одним корнем; различаются только ролью.

Типичный контроллер, отдающий страницы:

main/PageController.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, расширение дописывается само.

Сменить корень целиком можно один раз при запуске приложения:

php
ResponseView::setBasePath(__DIR__ . '/theme');

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

Шаблоны не сканируются — и это правильно

Каталог resources/ исключён из скана классов намеренно: шаблон это PHP-файл, и сканер, найдя в нём объявление класса, подключил бы его при старте — то есть выполнил бы разметку. Держите шаблоны в resources/, а классы — вне его.

Данные шаблона

Все ключи переданного массива становятся в шаблоне переменными:

resources/views/user/profile.php
<h1><?= htmlspecialchars($user->name) ?></h1>
<p>Регистрация: <?= $user->createdAt->format('d.m.Y') ?></p>

Те же данные видны и в макете, и в частичных шаблонах — передавать их дальше вручную не нужно.

Рядом с ними всегда есть $data — весь массив целиком. Пригождается, когда ключи заранее неизвестны или их нужно передать дальше пачкой:

resources/views/partial/debug.php
<?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-класс, если это текущий адрес

Макет целиком выглядит так:

resources/views/layouts/main.php
<!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 останется подсвеченным.

resources/views/partial/nav.php
<nav>
  <a class="<?= wrIsActiveLink('/cabinet/profile') ?>" href="/cabinet/profile">Профиль</a>
  <a class="<?= wrIsActiveLink('/cabinet/orders') ?>" href="/cabinet/orders">Заказы</a>
</nav>

По умолчанию возвращается active при совпадении и пустая строка иначе; оба класса переопределяются вторым и третьим аргументами. Первым можно передать массив адресов — тогда пункт считается активным на любом из них:

php
wrIsActiveLink(['/cabinet/orders', '/cabinet/orders/archive'], 'is-current', 'is-muted')

Код ответа и заголовки

ResponseView — такой же билдер, как остальные ответы: третьим аргументом идёт HTTP-код, заголовки добавляются цепочкой.

php
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.

Дальше