3. Валідація

HTML-форма без перевірки полів швидко приймає сміття: порожнє ім’я, «email» без @, коротке повідомлення. Можна перевіряти в controller купою if — працює на одній сторінці, але правила розмазуються по action-ах і погано діляться з API. Тому валідацію виносимо в окремий клас правил (ContactFormRequest): controller вирішує лише, що показати при помилці чи успіху.

Capability addValidation() підключаємо явно — без неї factory FormRequest у контейнері немає. CSRF ще немає (крок 5): спочатку видно чисту валідацію вводу. ErrorHandling теж пізніше — тому помилки форми показуємо вручну в контролері, без прихованого exception→flash middleware. Так легше зрозуміти обидві гілки відповіді, перш ніж «сховати» їх за type-hint.

Що додається і навіщо

  • addValidation() — validator (Rakit) + FormRequest factory; правила живуть у класах, не в анонімних масивах у action
  • ContactFormRequest — rules і повідомлення в одному місці: UI і API можуть ділити той самий клас
  • GET|POST /contact — окрема сторінка демо, щоб не змішувати промо-головну з формою
  • index.twig — посилання на /contact у nav, hero CTA і блоці Flow (крок 3)
  • public/.htaccess — rewrite на index.php (інакше Apache не дійде до Router для /contact)

withFormRequests() на Http не потрібен для цього кроку: він лише додає argument resolver для type-hint у action. Ми викликаємо factory вручну, щоб побачити гілку «невалідно → знову відмалювати форму» без залежності від ErrorHandling. Type-hint з’явиться на кроці 7, коли буде middleware на ValidationException.

ApplicationStackBootstrap.php

Ми викликаємо addValidation(), щоб ізолювати перевірку вводу: той самий validator і FormRequest factory потім знадобляться і web-формі, і JSON API. Http і View лишаються як були — валідація не тягне за собою session чи CSRF «за компанію». Порядок у Bootstrap тут не критичний для HTTP, але Validation зручно ставити рано: вона не залежить від View.

$stack->addValidation();

$stack->addHttp()
    ->setRoutes([$rootPath . 'routes/web.php']);

$stack->addView()
    ->withTwig()
    ->setViewsPath($rootPath . 'resources/views');

addValidation() уже реєструє FormRequest factory. Опційний лог — withLogging($path) на Validation builder (не плутати з capability addLogging()). Повний API — у Validation brick.

ContactFormRequest.php

Правила й тексти помилок тримаємо поруч із назвою форми: контролер не дублює рядки required|email, а UI може посилатися на ті самі ключі помилок (errors.email тощо). Коли з’явиться API (крок 4), той самий клас піде в JSON-handler — без копіпасти правил.

final class ContactFormRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => 'required|min:2',
            'email' => 'required|email',
            'message' => 'required|min:10',
        ];
    }

    public function messages(): array
    {
        return [
            'name:required' => 'Please enter your name.',
            // …
        ];
    }
}

Пакети — Form Request і Validation.

ContactController.php

Явний validate() у action показує обидві гілки відповіді (422 з errors/old або 200 з validated) без exception magic. Це навмисно «гучніше», ніж type-hint: ви бачите, що відбувається при помилці, до того як крок 7 сховає це в middleware.

old передаємо з $form->all(), щоб користувач не набирав поля з нуля після 422. Flash/session ще немає — усе в тому ж POST-response.

public function submit(ServerRequestInterface $request): ResponseInterface
{
    $form = $this->formRequests->make(ContactFormRequest::class, $request);

    if (!$form->validate()) {
        return $this->view->create('contact', [
            'errors' => $form->errors(),
            'old' => $form->all(),
            'success' => false,
            'validated' => null,
        ], HttpStatusCode::UNPROCESSABLE_ENTITY);
    }

    return $this->view->create('contact', [
        'errors' => [],
        'old' => [],
        'success' => true,
        'validated' => $form->validated(),
    ]);
}

Альтернатива з withFormRequests() — type-hint ContactFormRequest у action: resolver кине ValidationException, а ErrorHandling (крок 7) віддасть відповідь. Зараз ми цього навмисно не робимо.

routes/web.php

Два маршрути на одну форму: GET показує порожню сторінку, POST приймає дані. Імена (contact, contact.submit) знадобляться пізніше для URL generator і redirect back.

$router->get('/contact', [ContactController::class, 'show'])->setName('contact');
$router->post('/contact', [ContactController::class, 'submit'])->setName('contact.submit');

resources/views/index.twig

Промо-головна лишається окремо від форми. У верхній панелі — кнопка Contact на /contact; у hero поруч із «How it works» — GitHub на репозиторій рецептів (не дублюємо форму в hero).

<a class="btn-promo btn-promo-ghost" href="/contact">Contact</a>
…
<a class="btn-promo btn-promo-ghost" href="https://github.com/php-concept/concept-recipes" target="_blank" rel="noopener noreferrer">
    <i class="bi bi-github me-1" aria-hidden="true"></i> GitHub
</a>

resources/views/contact.twig

Форма читає errors.* і old.* з даних, які контролер передав явно — немає session flash «з повітря». Після успіху показуємо validated-дані з того ж response. Коли з’являться CSRF і redirect (кроки 5 і 7), ті самі ключі в шаблоні лишаться; зміниться лише, звідки їх бере controller / middleware.

Запуск і перевірка

Тег step-03-validation містить увесь код до цього кроку включно. Нижче — повний запуск з нуля. Очікуємо: невалідний POST → 422 і помилки в UI; валідний POST → 200 і зелений alert з validated-даними.

Запуск

git checkout step-03-validation
rm -rf storage/cache storage/sessions storage/logs
composer install
cp docker-compose.yml.example docker-compose.yml
docker compose up -d --build

Перевірка в браузері

  1. Відкрийте http://localhost:8089/ і натисніть Contact у nav (або перейдіть на /contact).
  2. Надішліть порожню або невалідну форму (наприклад email bad, коротке повідомлення) — поля з is-invalid, тексти помилок під полями.
  3. Заповніть коректно: ім’я (≥2 символи), email, повідомлення (≥10 символів) — зелений alert OK. з validated-значеннями.

Перевірка з CLI

Те саме, що в браузері, але через HTTP-коди — зручно для CI або швидкої перевірки без UI.

curl -sI http://localhost:8089/contact | head -5
# Content-Type: text/html

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8089/contact \
  -d 'name=a&email=bad&message=hi'
# 422

curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8089/contact \
  -d 'name=Anna&email=anna@example.com&message=Hello there friend'
# 200

Ланцюжок

Validation живе всередині action, не в middleware. Тому ланцюжок короткий: Router → controller → factory → validate → Twig.

addValidation() + addHttp() + addView()
POST /contact
  → ContactController::submit()
    → FormRequestFactory::make(ContactFormRequest)
    → validate() → errors + old  або  validated()
  → contact.twig (422 або 200)

Крок 4 — JSON API (окремий api-трек): той самий FormRequest, інший транспорт. Далі на web — Session і CSRF. Усі кроки — Огляд.