Обработка ошибок и исключений в PHP: разбор на живом примере
Пользователь оплатил заказ, платёж прошёл, но письмо не ушло — а он видит белый экран. Разбираем, как в PHP 8.x разделять ошибки и исключения, чтобы такие случаи не терялись молча.

Сценарий из жизни интернет-магазина на чистом PHP 8.2. Клиент оплатил заказ через платёжный шлюз, транзакция прошла, деньги списаны. Дальше код должен создать запись в базе, отправить письмо и показать страницу «спасибо». Но SMTP-сервер отвалился по таймауту, при этом display_errors в проде выключен — и клиент видит пустую белую страницу. Заказ в базе есть, письма нет, менеджер о заказе не знает, клиент звонит и ругается.
Корень проблемы не в SMTP. Он в том, что код смешивает три разных механизма PHP — ошибки уровня движка, исключения и коды возврата функций — и обрабатывает их как попало. Разберём, как навести порядок, чтобы сбой в одном шаге не рушил весь запрос молча.
Три вещи, которые в PHP называют «ошибкой»
Прежде чем ловить — надо понять, что именно летит. В PHP 8.x есть три разных сущности, и обрабатываются они по-разному.
- Ошибки движка —
E_WARNING,E_NOTICE,E_DEPRECATEDи подобные. Их генерирует сам интерпретатор: обращение к несуществующему ключу массива, деление на строку, вызовfile_get_contentsпо недоступному URL. По умолчанию они не прерывают выполнение, а печатаются или пишутся в лог. - Исключения — объекты, наследующие
ThrowableчерезException. Их бросают либо вы черезthrow, либо библиотеки. Ловятся черезtry/catch. - Фатальные ошибки как объекты — с PHP 7 многое, что раньше валило скрипт насмерть (вызов метода у
null, деление на ноль, обращение к несуществующему классу), теперь бросается какError— тоже потомокThrowable. Их можно поймать вcatch.
Ключевой факт PHP 8: Error и Exception оба реализуют интерфейс Throwable. Поэтому catch (\Throwable $e) ловит и то, и другое. А вот catch (\Exception $e) не поймает TypeError или DivisionByZeroError — это частая ловушка.
Throwable
Базовый интерфейс, который реализуют все «бросаемые» объекты в PHP: и Exception, и Error. Появился в PHP 7. Ловить Throwable стоит на верхнем уровне приложения, чтобы не пропустить ни исключение бизнес-логики, ни падение движка.
Как выглядит проблемный код
Вот упрощённая версия обработчика заказа с типичными огрехами. PHP 8.2.
<?php
// Проблемный вариант: ошибки теряются
function processOrder(array $data): void
{
$orderId = saveOrder($data); // может вернуть false при сбое БД
// Результат saveOrder не проверяется вообще
sendConfirmationEmail($data['email'], $orderId);
echo 'Спасибо за заказ #' . $orderId;
}
function sendConfirmationEmail(string $to, int $orderId): void
{
// mail() при сбое вернёт false, но код это игнорирует
mail($to, 'Заказ ' . $orderId, 'Ваш заказ принят');
}
Что здесь не так по шагам:
saveOrderможет вернутьfalse— тогда$orderIdстанетfalse, аechoнапечатает «Спасибо за заказ #» без номера. Клиент решит, что всё хорошо.sendConfirmationEmailглотает результатmail(). Письмо не ушло — никто не узнал.- Если внутри
saveOrderвылетитPDOException, а обработчика нет, при выключенномdisplay_errorsклиент увидит белый экран.
Переписываем: исключения вместо кодов возврата
Первое правило: функция, которая не смогла выполнить свою работу, должна бросать исключение, а не возвращать false. Возврат false легко проигнорировать; исключение проигнорировать нельзя — оно поднимется вверх, пока его не поймают.
<?php
// Собственные типы исключений для разных проблем
class OrderSaveException extends \RuntimeException {}
class EmailSendException extends \RuntimeException {}
function saveOrder(array $data): int
{
try {
$pdo = getPdo(); // ваша фабрика соединения
$stmt = $pdo->prepare(
'INSERT INTO orders (email, total) VALUES (:email, :total)'
);
$stmt->execute([
'email' => $data['email'],
'total' => $data['total'],
]);
return (int) $pdo->lastInsertId();
} catch (\PDOException $e) {
// Оборачиваем низкоуровневое исключение в доменное,
// сохраняя причину через второй аргумент конструктора
throw new OrderSaveException(
'Не удалось сохранить заказ',
0,
$e
);
}
}
function sendConfirmationEmail(string $to, int $orderId): void
{
$sent = mail($to, 'Заказ ' . $orderId, 'Ваш заказ принят');
if ($sent === false) {
throw new EmailSendException(
"Письмо по заказу #{$orderId} не отправлено"
);
}
}
Обратите внимание на третий аргумент new OrderSaveException(..., 0, $e) — это previous. Он сохраняет исходное PDOException внутри вашего доменного исключения. В логе вы увидите всю цепочку и поймёте, что реальная причина — например, «too many connections», а не абстрактный «не удалось сохранить заказ».
Разная реакция на разные сбои
Теперь главное: не все сбои равны. Провал записи заказа — это катастрофа, запрос надо прервать и показать честную ошибку. А неотправленное письмо — это неприятность: заказ-то сохранён, деньги списаны, откатывать нечего. Письмо можно поставить в очередь на повтор.
<?php
function processOrder(array $data): void
{
// Критично: без сохранённого заказа продолжать нельзя
$orderId = saveOrder($data);
// Некритично для клиента: заказ уже есть
try {
sendConfirmationEmail($data['email'], $orderId);
} catch (EmailSendException $e) {
// Логируем и ставим в очередь на повтор, но НЕ роняем запрос
error_log($e->getMessage());
enqueueEmailRetry($data['email'], $orderId);
}
echo 'Спасибо за заказ #' . $orderId;
}
Здесь OrderSaveException сознательно не ловится в processOrder — пусть поднимается на верхний уровень, где сработает глобальный обработчик и покажет клиенту страницу «попробуйте позже». А EmailSendException гасится локально, потому что бизнес-последствие у него другое.
Ловите исключение там, где можете принять по нему осмысленное решение. Если в текущей функции вы не знаете, что делать со сбоем, — не ловите, дайте ему подняться выше.
Глобальный обработчик: последняя линия обороны
Всё, что не поймано локально, должно упереться в один обработчик на весь вход в приложение. Регистрируется он через set_exception_handler для непойманных Throwable и set_error_handler для ошибок движка.
<?php
// Ставится один раз при старте приложения (bootstrap)
// Превращаем предупреждения и notice в исключения,
// чтобы они не терялись в логе незамеченными
set_error_handler(function (
int $severity,
string $message,
string $file,
int $line
): bool {
// Уважаем текущий error_reporting (@ подавление)
if (!(error_reporting() & $severity)) {
return false;
}
throw new \ErrorException($message, 0, $severity, $file, $line);
});
// Единая точка для всего, что не поймали выше
set_exception_handler(function (\Throwable $e): void {
// Полная информация — в лог, не пользователю
error_log(sprintf(
"%s: %s in %s:%d",
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine()
));
http_response_code(500);
echo 'Что-то пошло не так. Попробуйте позже.';
});
Приём с set_error_handler, который бросает ErrorException, превращает молчаливые E_WARNING (тот самый сбой mail() или file_get_contents) в полноценные исключения. Это спорно: часть кода ломается, если она рассчитывала на «мягкие» предупреждения. Внедряйте постепенно и проверяйте на стейджинге.
finally: то, что выполнится в любом случае
Блок finally выполнится и при успехе, и при исключении, и даже при return из try. Место для освобождения ресурсов: закрыть файл, отпустить блокировку, откатить транзакцию.
<?php
function transferWithLock(int $orderId): void
{
$pdo = getPdo();
$pdo->beginTransaction();
try {
// ... операции внутри транзакции ...
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e; // пробрасываем дальше после отката
}
}
Здесь откат делается в catch, потому что commit в try не должен откатываться. Если бы логика была «всегда закрыть соединение независимо от исхода» — это ушло бы в finally.
Типичные ошибки при переписывании
- Пустой
catch.catch (\Throwable $e) {}без тела — глушитель, который прячет проблему намертво. Минимум —error_log. - Ловля
\Exceptionвместо\Throwableна верхнем уровне.TypeErrorиDivisionByZeroError— потомкиError, а неException, и в такой блок не попадут. - Потеря
previous. Оборачивая исключение, всегда передавайте исходное третьим аргументом, иначе в логе не будет реальной причины. - Исключения ради потока управления. Пустая корзина или неверный пароль — это не исключительная ситуация, а ожидаемый ход событий. Бросать на них исключения дорого и запутывает логику.
Что делать дальше
Настройте запись логов в файл или систему сбора (error_log в php.ini с путём вне веб-корня), включите log_errors=On и держите display_errors=Off на проде. Дальше стоит посмотреть на PSR-3 (Psr\Log\LoggerInterface) — стандартный интерфейс логирования, под который написаны Monolog и логгеры всех фреймворков; он позволит заменить error_log на структурированные записи с уровнями. Метрика, за которой смотреть в проде, — количество непойманных Throwable в час: рост означает, что где-то появился необработанный путь.
Мотивационное письмо и эссе: Поступление, стипендии, гранты, обмен
Спросить за 25 ₽Источники: PHP Manual: Exceptions, PHP Manual: Predefined Exceptions and Errors (Throwable)
Частые вопросы
Чем catch (\Throwable) отличается от catch (\Exception) в PHP 8?
У меня PHP 7.4, а не 8.x — код из статьи сработает?
Стоит ли превращать все warning в исключения через set_error_handler?
Что будет, если исключение вылетит внутри самого set_exception_handler?
Нужно ли ловить исключение прямо там, где оно возникло?
Не сработало: после set_error_handler мой @ перед функцией перестал подавлять ошибки. Почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.