10. Casting і DTO

До цього кроку додаток уже повний для демо: форми, API, БД, помилки, логи. Але після валідації controller усе ще бачить array рядків з HTTP — $validated['email']. Бізнес-код хоче typed об’єкт (ContactDto), а не «мішок» ключів. Без спільного casting шару кожен action пише свій hydrate — і роз’їжджаються крайні випадки.

Casting — опційна capability: Validation і FormRequest працюють без неї (кроки 3–9 це довели). Підключаємо, коли потрібен toDto() і typed параметри маршруту (int $id з {id}). Пакет — Valinor через extension-casting-valinor; у Stack — Casting brick.

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

  • addCasting() — CasterInterface у контейнері; FormRequest може викликати toDto()
  • ContactDto + ContactFormRequest::$dtoClass — validated array → typed об’єкт
  • withTypedRouteParameters() на Http — {id} у URL → int $id у action (GET /contact/{id})
  • contact-show.twig — перегляд збереженого запису
  • Controllers: insert($dto->toArray()) замість ручного мапінгу полів

ApplicationStackBootstrap.php

addCasting() реєструємо явно. Cache dir — для Valinor mapper cache; setDebug($debug) у dev (FileWatchingCache), щоб зміни DTO підхоплювались без ручного flush. Той самий $debug, що на кроці 7 для ErrorHandling і Twig.

На View (як і з кроку 7) — setCacheDir + setDebug($debug) для Twig compile cache.

$stack->addCasting()
    ->setCacheDir($rootPath . 'storage/cache/casting')
    ->setDebug($debug);

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

withTypedRouteParameters() потребує addCasting() — resolver кастить сегменти маршруту до scalar type-hints у controller. Для GET /contact/{id} action отримує int $id, без ручного (int) $request->…. Повний API — Casting brick, Http brick.

ContactDto + FormRequest

DTO наслідує Dto з пакета Casting і описує поля після валідації. У FormRequest задаємо $dtoClass — без цього toDto() поверне null навіть із увімкненим caster.

final class ContactDto extends Dto
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
        public readonly string $message,
    ) {}
}

final class ContactFormRequest extends FormRequest
{
    protected ?string $dtoClass = ContactDto::class;
    // rules() / messages() — як на кроці 3
}

Controllers

Web і API більше не читають $validated['…'] — беруть $form->toDto(). Поля DTO збігаються з колонками таблиці — insert($dto->toArray()) (created_at дає default у міграції).

public function submit(ContactFormRequest $form): ResponseInterface
{
    $dto = $form->toDto();
    $this->database->capsule()->table('contact_submissions')
        ->insert($dto->toArray());
}

public function showById(int $id): ResponseInterface
{
    $row = $this->database->capsule()
        ->table('contact_submissions')->where('id', $id)->first();
    // contact-show.twig: found / 404
}

Маршрут GET /contact/{id} у routes/web.php — після seed/migrate відкрийте /contact/1, щоб побачити typed $id в дії.

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

Тег step-10-casting. Після валідного POST контакт зберігається через DTO (toDto() → toArray()).

Запуск

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

docker compose exec app php bin/console.php db:migrate
docker compose exec app php bin/console.php db:seed

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

  1. Відкрийте http://localhost:8089/contact, заповніть і надішліть форму — зелений alert з validated-даними.
  2. Перевірте http://localhost:8089/api/stats — лічильник submissions зріс.
  3. Відкрийте http://localhost:8089/contact/1 — сторінка submission (typed {id} → int $id).

Перевірка з CLI

curl -sS -X POST http://127.0.0.1:8089/api/contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"Ada","email":"ada@example.com","message":"Hello casting world"}'
# saved: true

curl -s http://localhost:8089/api/stats
# contact_submissions_total зріс

Ланцюжок

POST /contact (valid)
  → FormRequest validate
  → toDto() → ContactDto (Caster / Valinor)
  → insert($dto->toArray()) + view

GET /contact/{id}
  → withTypedRouteParameters: {id} → int $id
  → contact-show.twig (found або 404)

Далі — 11. Config: literal paths і getenv у Bootstrap замінити на іменовані шляхи й config keys. Усі кроки — Огляд. Brick — Casting.