7. Обробка помилок
До цього кроку додаток уже вміє багато корисного — форми, сесію, БД — але «погані» ситуації все ще виглядають як сирий PHP-фатал або купа коду в controller. Два різні болі:
-
Несподіваний збій (
/boom, 404, bug у коді): користувачу не можна показувати stack trace у production, а розробнику навпаки потрібен Whoops. Хтось має повідомити про помилку (хоча б у PHPerror_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
Перевірка в браузері
-
Відкрийте
http://localhost:8089/contact,
надішліть невалідні дані — після redirect поля з
is-invalidі тексти помилок. -
Відкрийте
http://localhost:8089/boom
— при
APP_DEBUG=1сторінка Whoops; приAPP_DEBUG=0— HTML «500 — Внутрішня помилка». - Відкрийте 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().
Усі кроки — Огляд.