7. Обробка помилок

До цього кроку додаток уже вміє багато корисного — форми, сесію, БД — але «погані» ситуації все ще виглядають як сирий PHP-фатал або купа коду в controller. Два різні болі:

  • Несподіваний збій (/boom, 404, bug у коді): користувачу не можна показувати stack trace у production, а розробнику навпаки потрібен Whoops. Хтось має повідомити про помилку (хоча б у PHP error_log) і відмалювати безпечну сторінку.
  • Очікувана помилка форми (невалідний email): це не «аварія сервера», а нормальний UX. Користувач має повернутися на форму з підсвіченими полями. Поки ми робили це вручну в ContactController — працює на одній сторінці, але погано масштабується, коли форм стане кілька.

Тому на цьому кроці з’являється capability addErrorHandling() (Whoops / fallback / report) і водночас «закриваємо» web-форму: type-hint FormRequest кидає ValidationException, middleware робить flash + redirect, а спільні дані для шаблону збирає один ShareViewDataMiddleware. Monolog навмисно ще немає — окремий крок, щоб не змішувати «як показуємо помилку» з «куди пишемо логи».

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

  • addErrorHandling() — єдине місце, де вирішуємо: debug → Whoops, production → HTML без секретів, report → error_log (поки без файлу Monolog)
  • withFormRequests() — щоб action міг приймати ContactFormRequest $form: валідація до входу в метод, а не купа if (!$form->validate()) у кожному handler
  • HandleValidationExceptionMiddleware — перетворює «валідація не пройшла» на зрозумілу відповідь (redirect + flash або 422 JSON), замість падіння в загальний ErrorHandling як 500
  • ShareViewDataMiddleware — один раз кладе в запит те, що майже кожна HTML-сторінка з формою все одно потребує (csrf, flashes, errors, old)
  • GET /boom і PHP-файли fallback — щоб руками перевірити обидва режими APP_DEBUG, а не «повірити на слово»

ApplicationStackBootstrap.php

ErrorHandling підключаємо після Http і View: rendererу потрібні response factory і (для HTML) view. setDebug читає APP_DEBUG з оточення Docker — той самий прапорець, яким увімкнеш Whoops локально і вимкнеш на проді, не переписуючи Bootstrap.

setLogReporting(logger: false, phpErrorLog: true) — свідомий компроміс цього кроку: помилку все одно треба кудись записати, але capability Logging ще немає. Коли з’явиться Monolog, зміните лише цей виклик (увімкнете logger: true), не переробляючи Whoops і fallback. Повний API builder-а — ErrorHandling brick.

withFormRequests() на Http потрібен не для Whoops, а для наступної частини кроку: без нього type-hint FormRequest у action просто не резолвиться. Раніше ми викликали factory вручну саме тому, що ще не було middleware на ValidationException.

На цьому кроці з’являється $debug — той самий прапорець для ErrorHandling, Twig і (з кроку 10) Casting. У View додаємо setCacheDir() разом із setDebug($debug): при APP_DEBUG=1 compile cache вимкнений, при APP_DEBUG=0 — увімкнений (production-like).

$debug = getenv('APP_DEBUG') !== '0';

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

$stack->addView()
    ->withTwig()
    ->setViewsPath($rootPath . 'resources/views')
    ->setCacheDir($rootPath . 'storage/cache/views')
    ->setDebug($debug);

// … console …

$stack->addErrorHandling()
    ->setDebug($debug, new PrettyPageHandler())
    ->setLogReporting(logger: false, phpErrorLog: true)
    ->withViewRenderer($rootPath . 'resources/views/errors/fallback');

routes/web.php

Порядок middleware — це історія запиту, не випадковий список. Спочатку запам’ятовуємо URL (StorePreviousUrl), інакше після помилки валідації/CSRF redirectBack не знатиме, куди вернути. Далі ловимо ValidationException до того, як вона вилетить у Whoops як «невідома аварія». CSRF — як і на кроці 5. В кінці — ShareViewData: він читає вже записаний flash після redirect і готує дані для Twig.

Група лише для HTML: API в routes/api.php — окремий JSON-ланцюжок з HandleJsonValidationExceptionMiddleware (без CSRF і flash). Web і API розділяємо middleware, а не дублюємо if (!$form->validate()) у кожному handler.

$router->group('', function (RouteGroup $router): void {
    $router->get('/', [IndexController::class, 'index'])->setName('home');
    $router->get('/boom', BoomController::class)->setName('boom');

    $router->get('/contact', [ContactController::class, 'show'])->setName('contact');
    $router->post('/contact', [ContactController::class, 'submit'])->setName('contact.submit');
})->lazyMiddlewares([
    StorePreviousUrlMiddleware::class,
    HandleValidationExceptionMiddleware::class,
    HandleCsrfExceptionMiddleware::class,
    VerifyCsrfTokenMiddleware::class,
    ShareViewDataMiddleware::class,
]);

routes/api.php

Після withFormRequests() API може приймати ContactFormRequest type-hint у action — resolver кидає ValidationException, як і на web. Але JSON-клієнту не потрібні flash і redirect: тому окремий middleware ловить exception і завжди відповідає 422 з errors.

$router->group('/api', function (RouteGroup $router): void {
    $router->get('/ping', [ApiController::class, 'ping'])->setName('api.ping');
    $router->get('/stats', [ApiController::class, 'stats'])->setName('api.stats');
    $router->post('/contact', [ApiController::class, 'contact'])->setName('api.contact');
})->lazyMiddlewares([
    ParseJsonBodyMiddleware::class,
    ForceJsonResponseMiddleware::class,
    HandleJsonValidationExceptionMiddleware::class,
]);

HandleJsonValidationExceptionMiddleware.php

Дзеркало web-middleware для API surface: без сесії, без RequestFormat::expectsJson() — група /api і так завжди JSON. Controller лишається тонким: type-hint ContactFormRequest, далі validated() і insert.

try {
    return $handler->handle($request);
} catch (ValidationException $e) {
    return $this->responseFactory->jsonError(
        $e->getMessage(),
        HttpStatusCode::UNPROCESSABLE_ENTITY,
        $e->getErrors(),
    );
}
public function contact(ContactFormRequest $form): ResponseInterface
{
    $validated = $form->validated();

    $this->database->capsule()->table('contact_submissions')->insert([
        'name' => $validated['name'],
        'email' => $validated['email'],
        'message' => $validated['message'],
        'created_at' => date('Y-m-d H:i:s'),
    ]);

    return $this->response->jsonSuccess([
        'validated' => $validated,
        'saved' => true,
    ]);
}

SessionKey.php

На кроці 5 у SessionKey вже є URL_CURRENT і URL_PREVIOUS для StorePreviousUrlMiddleware. Тут додаємо ключі flash для redirect після валідації — щоб HandleValidationExceptionMiddleware і ShareViewDataMiddleware читали/писали одні й ті самі імена, без magic strings у кількох файлах.

final class SessionKey
{
    public const string VALIDATION_ERRORS = '_validation_errors';
    public const string VALIDATION_DATA = '_validation_data';
    public const string URL_PREVIOUS = '_url_previous';
    public const string URL_CURRENT = '_url_current';
}

HandleValidationExceptionMiddleware.php

Коли FormRequest-resolver бачить невалідні дані, він кидає ValidationException — не повертає «м’який» false у controller. Без цього middleware exception пішов би в ErrorHandling і виглядав би як серверна помилка (Whoops / 500). Але для користувача форми це звична ситуація: «виправте поля».

Тому middleware розрізняє два світи. Якщо клієнт чекає JSON (Accept) — віддаємо 422 з errors, як і раніше робив API вручну. Якщо HTML — кладемо errors і old input у flash і робимо redirectBack: наступний GET знову відкриє форму, а не покаже POST з «мертвим» тілом після F5.

try {
    return $handler->handle($request);
} catch (ValidationException $e) {
    if ($this->requestFormat->expectsJson($request)) {
        return $this->responseFactory->jsonError(
            $e->getMessage(),
            HttpStatusCode::UNPROCESSABLE_ENTITY,
            $e->getErrors(),
        );
    }

    $this->flashBag->addError($e->getMessage());
    $this->flashBag->set(SessionKey::VALIDATION_ERRORS, $e->getErrors());
    $this->flashBag->set(SessionKey::VALIDATION_DATA, $e->getOldData());

    return $this->responseFactory->redirectBack(request: $request);
}

ShareViewDataMiddleware.php

Після redirect на /contact шаблону знову потрібні ті самі чотири речі: CSRF-токен для форми, flashes (повідомлення після CSRF mismatch), errors по полях і old input, щоб користувач не набирав усе з нуля. На кроках 3–5 ми передавали це явно з кожного controller — зручно, поки сторінка одна. Коли з’являється redirect+flash і кілька action-ів (show, submit, пізніше інші форми), той самий масив починають копіювати скрізь.

ShareViewDataMiddleware збирає ці дані один раз за запит і кладе в атрибут VIEW_PAYLOAD (Concept\Extensions\View\Requests\RequestAttribute). ViewResponseFactory::create() підхоплює атрибут і мерджить його з тим, що передав controller. Тому ContactController більше не інжектить CSRF і FlashBag лише заради шаблону: він передає лише те, що унікальне для відповіді (success, validated).

Коли це варто робити: HTML-поверхня з формами + flash після redirect. Коли ні: чистий JSON API (там немає Twig) або сторінка без спільних змінних — middleware можна не вішати на ту групу маршрутів.

final class ShareViewDataMiddleware implements MiddlewareInterface
{
    private const string TEMPLATE_ERRORS = 'errors';
    private const string TEMPLATE_OLD_INPUT = 'old';
    private const string TEMPLATE_FLASHES = 'flashes';
    private const string TEMPLATE_CSRF_TOKEN = 'csrf_token';

    // …

    return $handler->handle($request->withAttribute(RequestAttribute::VIEW_PAYLOAD, [
        self::TEMPLATE_ERRORS => $this->flashBag->get(SessionKey::VALIDATION_ERRORS, []),
        self::TEMPLATE_OLD_INPUT => $this->flashBag->get(SessionKey::VALIDATION_DATA, []),
        self::TEMPLATE_FLASHES => $this->flashBag->all(),
        self::TEMPLATE_CSRF_TOKEN => $this->csrfTokenManager->getToken(),
    ]));
}

ContactController.php

Порівняйте з кроком 3: тоді submit сам викликав validate() і в гілці «помилка» знову малював Twig з errors/old. Тепер сигнатура ContactFormRequest $form означає: «якщо ви тут — дані вже валідні». Невалідний POST навіть не заходить у метод — його перехопить middleware вище.

Controller лишається тонким: зберегти в БД і показати успіх. Усе «сервісне» для шаблону (токен, flash, errors) уже в VIEW_PAYLOAD. Так легше читати бізнес-історію сторінки і не змішувати її з інфраструктурою форми.

final class ContactController
{
    public function __construct(
        private readonly ViewResponseFactoryInterface $view,
        private readonly DatabaseInterface $database,
    ) {}

    public function show(): ResponseInterface
    {
        return $this->view->create('contact', [
            'success' => false,
            'validated' => null,
        ]);
    }

    public function submit(ContactFormRequest $form): ResponseInterface
    {
        $validated = $form->validated();

        $this->database->capsule()->table('contact_submissions')->insert([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'message' => $validated['message'],
            'created_at' => date('Y-m-d H:i:s'),
        ]);

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

BoomController.php і fallback

Whoops і fallback важко «відчути» на щасливому шляху форми. /boom навмисно кидає exception: з APP_DEBUG=1 ви бачите детальний Whoops (швидкий діагноз під час розробки); з APP_DEBUG=0 — спокійну HTML-сторінку з resources/views/errors/fallback/500.php, без шляхів і секретів. 404 для неіснуючого URL працює так само через 404.php.

Файли fallback — простий PHP, не Twig: ErrorHandling спочатку шукає іменований view-шаблон помилки, а якщо його немає (у рецепті ще немає @frontend/errors/…), підключає саме цей каталог. Так production-відповідь не залежить від того, чи зібрався Twig-кеш.

final class BoomController
{
    public function __invoke(): never
    {
        throw new RuntimeException('Recipe boom — Whoops (APP_DEBUG=1) або fallback HTML (APP_DEBUG=0).');
    }
}

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

Тег step-07-error-handling. Три «погані» шляхи: невалідна форма → redirect з помилками; /boom → Whoops (debug) або fallback 500; неіснуючий URL → 404.

Запуск

git checkout step-07-error-handling
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, надішліть невалідні дані — після redirect поля з is-invalid і тексти помилок.
  2. Відкрийте http://localhost:8089/boom — при APP_DEBUG=1 сторінка Whoops; при APP_DEBUG=0 — HTML «500 — Внутрішня помилка».
  3. Відкрийте http://localhost:8089/missing — HTML «404 — Не знайдено».

Перевірка з CLI

# 1) невалідна форма → 302 на /contact, у HTML з’являться errors / old
COOKIE=/tmp/c.txt
curl -s -c $COOKIE http://localhost:8089/contact >/dev/null
TOKEN=$(curl -s -b $COOKIE http://localhost:8089/contact \
  | sed -n 's/.*name="_csrf_token" value="\([^"]*\)".*/\1/p')
curl -s -b $COOKIE -c $COOKIE -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8089/contact \
  -d "name=a&email=bad&message=hi&_csrf_token=$TOKEN"
# 302
curl -s -b $COOKIE http://localhost:8089/contact | grep -o 'is-invalid' | head -1

# 2) API: невалідний JSON → 422 (без ручного validate у controller)
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8089/api/contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"a","email":"bad","message":"hi"}'
# 422

# 3) debug: Whoops
curl -s http://localhost:8089/boom | head

# 4) production: у docker-compose поставте APP_DEBUG=0, recreate app
# curl -s http://localhost:8089/boom     → HTML «500 — Внутрішня помилка»
# curl -s http://localhost:8089/missing  → HTML «404 — Не знайдено»

Ланцюжок

POST /contact (invalid)
  → CSRF ok → FormRequest resolver кидає ValidationException
  → HandleValidationException: flash errors/old + redirectBack
  → GET /contact → ShareViewData наповнює VIEW_PAYLOAD
  → contact.twig показує підсвічені поля

GET /boom
  → RuntimeException
  → report у php error_log (крок 7); з кроку 8 також у Monolog
  → APP_DEBUG=1: Whoops  |  APP_DEBUG=0: fallback/500.php

POST /api/contact (invalid JSON)
  → FormRequest resolver кидає ValidationException
  → HandleJsonValidationExceptionMiddleware → jsonError 422

Далі — 8. Логування і маскування: Monolog, setLogReporting(logger: true) і addMasking() / withMasking(). Усі кроки — Огляд.