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

Крок 3 — Валідація (addValidation(), FormRequest): перевірка полів форми без CSRF і без ErrorHandling — щоб спочатку побачити чисту гілку «невалідно → знову шаблон». Огляд — Огляд.