PHP / Безопасность
Ошибки и исключения в PHP: обработка, логирование и безопасный ответ
Как использовать Throwable, try/catch/finally, собственные исключения, глобальный обработчик, request ID и production-лог без утечки деталей.
Содержание статьи
Ошибка приложения должна привести к предсказуемому результату: связная операция откатывается, технические детали попадают в защищённый лог, а пользователь получает нейтральное сообщение и идентификатор запроса.
Нельзя просто включить показ всех ошибок в production. Путь к файлу, SQL, конфигурация и стек вызовов помогают атакующему и запутывают обычного посетителя.
Throwable, Exception и Error
В PHP и исключения приложения, и многие ошибки движка реализуют интерфейс Throwable:
Throwable├── Exception│ ├── RuntimeException│ ├── InvalidArgumentException│ └── ...└── Error ├── TypeError ├── ValueError └── ...Обычно перехватывайте наиболее конкретный тип, который умеете обработать. Глобальная граница HTTP-запроса может перехватить Throwable, записать инцидент и вернуть 500.
Выброс исключения
function normalizeArticleId(int $id): int{ if ($id < 1) { throw new InvalidArgumentException('ID статьи должен быть положительным.'); } return $id;}Исключение означает, что функция не может выполнить свой контракт. Для ожидаемого результата «статья не найдена» иногда лучше вернуть null или специальный объект результата; выбор зависит от архитектуры.
Не используйте исключения вместо каждой ветки if.
try, catch, finally
try { $article = $repository->findById($articleId); if ($article === null) { http_response_code(404); echo 'Статья не найдена.'; return; } $publisher->publish($article);} catch (ArticleAlreadyPublished $error) { http_response_code(409); echo 'Статья уже опубликована.';} finally { $metrics->finishTimer('article.publish');}finally выполняется независимо от успеха и подходит для освобождения ресурса или завершения измерения. Не помещайте туда новое основное действие, способное скрыть исходную ошибку.
Собственные исключения
final class ArticleAlreadyPublished extends DomainException{}Отдельный тип позволяет перехватить конкретную ситуацию без анализа текста $error->getMessage(). Не создавайте десятки классов без потребности в разном поведении.
Транзакция
$database->beginTransaction();try { $orderId = $orders->create($userId, $total); $orderItems->addMany($orderId, $items); $database->commit();} catch (Throwable $error) { if ($database->inTransaction()) { $database->rollBack(); } throw $error;}Текущий слой откатывает ресурс, которым управляет, и повторно выбрасывает исключение. Публичный ответ формирует верхняя HTTP-граница.
Режим разработки и production
В разработке:
error_reporting(E_ALL);ini_set('display_errors', '1');ini_set('log_errors', '1');В production:
error_reporting(E_ALL);ini_set('display_errors', '0');ini_set('log_errors', '1');Лучше задавать эти параметры конфигурацией окружения, а не менять в каждом контроллере. Лог должен находиться вне web-root и иметь ограниченные права.
display_errors=0 не означает игнорирование ошибок: они по-прежнему регистрируются и должны попадать в мониторинг.
Глобальный обработчик исключений
Упрощённая граница для HTML-приложения:
<?phpdeclare(strict_types=1);$requestId = bin2hex(random_bytes(8));set_exception_handler( static function (Throwable $error) use ($requestId): void { error_log(sprintf( '[%s] %s: %s in %s:%d%s%s', $requestId, $error::class, $error->getMessage(), $error->getFile(), $error->getLine(), PHP_EOL, $error->getTraceAsString(), )); if (!headers_sent()) { http_response_code(500); header('Content-Type: text/html; charset=UTF-8'); header('X-Request-ID: ' . $requestId); } echo '<h1>Не удалось выполнить запрос</h1>'; echo '<p>Попробуйте ещё раз. Код обращения: ' . htmlspecialchars($requestId, ENT_QUOTES, 'UTF-8') . '</p>'; },);Лог содержит класс, место и стек, публичный ответ — только request ID. В реальном проекте используйте единый logger, очищайте чувствительные данные и учитывайте формат ответа маршрута.
Не записывайте пароли, session ID, токены, полные cookies и содержимое загруженных файлов.
Преобразование warning в исключение
Старые функции иногда сигнализируют через warning и false. Локально можно преобразовать диагностируемую ошибку:
set_error_handler( static function ( int $severity, string $message, string $file, int $line, ): bool { if (!(error_reporting() & $severity)) { return false; } throw new ErrorException($message, 0, $severity, $file, $line); },);Глобальная конвертация меняет поведение всего приложения и стороннего кода. Применяйте её только как осознанное архитектурное решение и тестируйте.
Часто яснее проверить результат конкретной функции:
$contents = file_get_contents($path);if ($contents === false) { throw new RuntimeException('Не удалось прочитать файл.');}JSON-ответ API
set_exception_handler( static function (Throwable $error) use ($requestId): void { error_log(sprintf('[%s] %s: %s', $requestId, $error::class, $error->getMessage())); if (!headers_sent()) { http_response_code(500); header('Content-Type: application/json; charset=UTF-8'); header('X-Request-ID: ' . $requestId); } echo json_encode([ 'error' => [ 'code' => 'internal_error', 'message' => 'Не удалось выполнить запрос.', 'request_id' => $requestId, ], ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); },);HTML и API не должны случайно смешиваться. В большом приложении формат выбирает отдельный error renderer по типу маршрута.
Ошибки валидации — не 500
Некорректный пользовательский ввод ожидаем. Верните форму с понятными ошибками или статус 422 для API. Не записывайте каждый пустой email как аварийное исключение.
Ориентиры HTTP:
- 400 — запрос синтаксически неприемлем;
- 401 — нужна аутентификация;
- 403 — действие запрещено;
- 404 — ресурс не найден;
- 409 — конфликт состояния;
- 422 — данные понятны, но не проходят правила;
- 500 — непредвиденная серверная ошибка.
Точный выбор зависит от контракта API и не должен раскрывать существование закрытого ресурса.
Что тестировать
- исключение до и после начала вывода;
- сбой базы внутри транзакции;
- повреждённый JSON и недоступный файл;
- отсутствие каталога логов или места на диске;
- HTML- и JSON-маршруты;
- уникальный request ID в ответе и логе;
- отсутствие секретов в журнале;
- нейтральный production-ответ при
display_errors=0.
Что запомнить
Перехватывайте исключение там, где можете восстановиться или корректно перевести его в результат. На верхней границе логируйте Throwable с request ID и возвращайте нейтральный ответ. Валидационные ошибки не являются аварией, а подробности production-сбоя не должны попадать пользователю.