2. HTML-сторінки
У попередньому кроці додаток відповідав простим текстом у body. Для демо це ок, але справжній HTML у контролері швидко стає нечитабельним: рядки розмітки змішуються з логікою відповіді, а зміна layout означає правки PHP. Тому виносимо розмітку в шаблони — контролер лишається тонким handler-ом, Twig відповідає лише за HTML.
Маршрути й HTTP-шар лишаються як були. View підключаємо
окремою capability: щоб можна було мати HTTP без шаблонів
(наприклад, на API-треку) і навпаки — не тягнути Twig «за компанію»
з addHttp().
Що додається і навіщо
-
addView()+withTwig()— шаблони з’являються лише після явного вибору engine (Twig XOR Plates); без цього виклику view services у контейнері немає -
setViewsPath()— каталог шаблонів explicit у Bootstrap; compile cache іsetDebug()— з кроку 7 разом із$debug(див. ErrorHandling) -
ViewResponseFactory— єдиний спосіб зібрати HTML response з шаблону (Content-Type і body), замість ручногоwrite('<html>…') -
Пакети View підтягує
php-concept/stack(extension-view, extension-view-twig) — окремо ставити їх у composer для цього кроку не потрібно
Bootstrap у composer.json і статика в public/css —
це UI рецепта, не частина View brick: Stack не нав’язує CSS-фреймворк.
Шаблон лише посилається на URL ассетів.
ApplicationStackBootstrap.php
Ми додаємо addView()->withTwig(), щоб відокремити
rendering від HTTP: без цього brick контролер не може
«випадково» відмалювати шаблон — сервісів просто немає.
Шляхи знову literal: config ще не маскує, звідки беруться
templates. Compile cache на ранніх кроках не вмикаємо — простіше
перемикати теги рецепту; з кроку 7 — разом із
setDebug($debug).
$stack->addHttp()
->setRoutes([$rootPath . 'routes/web.php']);
$stack->addView()
->withTwig()
->setViewsPath($rootPath . 'resources/views');
withTwig() повертає Twig-builder: обов’язково
setViewsPath(); setCacheDir() і
setDebug() — з кроку 7, коли з’являється
$debug. Суфікс файла
(setDefaultExtension(), default .twig) — за бажанням.
Альтернатива — withPlates() (XOR з Twig): один engine
на додаток, без змішування. Повний API —
у View brick; пакети —
extension-view і
extension-view-twig.
Коли варто: HTML-поверхня, листи, server-rendered сторінки. Коли ні: чистий JSON API — View на цьому checkout можна не додавати (див. крок 4).
IndexController.php
Замість ручного запису HTML у body інжектимо
ViewResponseFactoryInterface: контролер називає шаблон
і дані, а не збирає рядки розмітки. Так легше міняти UI без зміни
HTTP-логіки — і тести можуть підмінити factory.
<?php declare(strict_types=1);
namespace Concept\App\Controllers;
use Concept\Extensions\View\Contracts\ViewResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
class IndexController
{
public function __construct(
private readonly ViewResponseFactoryInterface $view,
) {}
public function index(): ResponseInterface
{
return $this->view->create('index');
}
}
create('index') рендерить index.twig,
виставляє Content-Type: text/html. Дані в шаблон —
другим аргументом: create('index', ['title' => '…']).
Пізніше той самий factory підхопить спільні дані з middleware
(VIEW_PAYLOAD) — але на цьому кроці controller ще
передає все сам.
resources/views/index.twig
Корінь шаблонів = те, що передали в setViewsPath().
Розширення .twig можна не писати в create()
(або задати своє через setDefaultExtension()).
У рецепті головна — промо Core · Extensions · Stack зі статикою
з /css/home.css і /js/home.js. У nav —
лише бренд; у hero поруч із «How it works» — кнопка
GitHub на репозиторій рецептів
(concept-recipes). Форми ще немає — кнопки Contact
з’являться з кроку 3.
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>Concept — Core · Extensions · Stack</title>
<link rel="stylesheet" href="/vendor/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/home.css">
</head>
<body>
{# … промо-розмітка … #}
<script src="/js/home.js" defer></script>
</body>
</html>
Статика і Bootstrap
Статика (CSS/JS) і Bootstrap лишаються звичайними файлами в
public/, а не «магією» View: шаблон лише посилається
на URL. Так легше кешувати ассети окремо від Twig compile cache
і не плутати «рендер сторінки» з «роздачею файлів».
composer install
# post-install публікує Bootstrap у public/vendor/
Запуск і перевірка
Код кроку — concept-recipes, тег
step-02-view. Очікуємо HTML-сторінку замість
Hello, world!: Content-Type: text/html і
body з <!DOCTYPE html>.
Запуск
git checkout step-02-view
rm -rf storage/cache storage/sessions storage/logs
composer install
cp docker-compose.yml.example docker-compose.yml
docker compose up -d --build
Перевірка в браузері
Відкрийте
http://localhost:8089/
— має з’явитись промо-сторінка Concept (не plain text).
У hero — «How it works» і посилання GitHub на
php-concept/concept-recipes.
Перевірка з CLI
Те саме, що в браузері, але через заголовки й фрагмент body.
curl -sI http://localhost:8089/ | head -5
# Content-Type: text/html
curl -s http://localhost:8089/ | head -20
# <!DOCTYPE html> … Concept
Що змінилось у ланцюжку
Entry point і Router ті самі. Нове — після dispatch контролер більше не пише body вручну: фабрика рендерить Twig і збирає text/html response.
ApplicationStackBootstrap
→ addHttp() + addView()->withTwig() → providers()
App::handle() → IndexController::index()
→ ViewResponseFactory::create('index')
→ Twig render resources/views/index.twig
→ text/html response