Ошибки и исключения в PHP: обработка, логирование и безопасный ответ

PHP / Безопасность

Ошибки и исключения в PHP: обработка, логирование и безопасный ответ

Как использовать Throwable, try/catch/finally, собственные исключения, глобальный обработчик, request ID и production-лог без утечки деталей.

Опубликовано 04.09.2026 5 минут чтения
Содержание статьи

Ошибка приложения должна привести к предсказуемому результату: связная операция откатывается, технические детали попадают в защищённый лог, а пользователь получает нейтральное сообщение и идентификатор запроса.

Нельзя просто включить показ всех ошибок в production. Путь к файлу, SQL, конфигурация и стек вызовов помогают атакующему и запутывают обычного посетителя.

Throwable, Exception и Error

В PHP и исключения приложения, и многие ошибки движка реализуют интерфейс Throwable:

Код
Throwable├── Exception│   ├── RuntimeException│   ├── InvalidArgumentException│   └── ...└── Error    ├── TypeError    ├── ValueError    └── ...​

Обычно перехватывайте наиболее конкретный тип, который умеете обработать. Глобальная граница HTTP-запроса может перехватить Throwable, записать инцидент и вернуть 500.

Выброс исключения

PHP
function normalizeArticleId(int $id): int{    if ($id < 1) {        throw new InvalidArgumentException('ID статьи должен быть положительным.');    }​    return $id;}​

Исключение означает, что функция не может выполнить свой контракт. Для ожидаемого результата «статья не найдена» иногда лучше вернуть null или специальный объект результата; выбор зависит от архитектуры.

Не используйте исключения вместо каждой ветки if.

try, catch, finally

PHP
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 выполняется независимо от успеха и подходит для освобождения ресурса или завершения измерения. Не помещайте туда новое основное действие, способное скрыть исходную ошибку.

Собственные исключения

PHP
final class ArticleAlreadyPublished extends DomainException{}​

Отдельный тип позволяет перехватить конкретную ситуацию без анализа текста $error->getMessage(). Не создавайте десятки классов без потребности в разном поведении.

Транзакция

PHP
$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

В разработке:

PHP
error_reporting(E_ALL);ini_set('display_errors', '1');ini_set('log_errors', '1');​

В production:

PHP
error_reporting(E_ALL);ini_set('display_errors', '0');ini_set('log_errors', '1');​

Лучше задавать эти параметры конфигурацией окружения, а не менять в каждом контроллере. Лог должен находиться вне web-root и иметь ограниченные права.

display_errors=0 не означает игнорирование ошибок: они по-прежнему регистрируются и должны попадать в мониторинг.

Глобальный обработчик исключений

Упрощённая граница для HTML-приложения:

PHP
<?php​declare(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. Локально можно преобразовать диагностируемую ошибку:

PHP
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);    },);​

Глобальная конвертация меняет поведение всего приложения и стороннего кода. Применяйте её только как осознанное архитектурное решение и тестируйте.

Часто яснее проверить результат конкретной функции:

PHP
$contents = file_get_contents($path);​if ($contents === false) {    throw new RuntimeException('Не удалось прочитать файл.');}​

JSON-ответ API

PHP
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-сбоя не должны попадать пользователю.

Сообщить об ошибке в статье

Рекомендации редакции

Другие публикации из этого раздела.

Новые материалы

Получайте практические разборы без информационного шума

Одна общая рассылка о новых статьях и инструментах. Адрес будет активироваться только после подтверждения.

Подписка появится после подключения сервиса рассылок и Yandex SmartCaptcha.