Исключения и их иерархия в PHP: от Throwable до своих исключений
Исключение — это объект, который выбрасывается при ошибке через throw и перехватывается блоком try/catch: вместо «функция вернула false, а вы гадайте почему» ошибка несёт сообщение, код и стек вызовов. В PHP весь мир ошибок делится на два корня: Throwable — интерфейс, от которого наследуются Error (фатальные ошибки движка: TypeError, DivisionByZeroError) и Exception (наши ошибки и ошибки библиотек). Понимать иерархию нужно, чтобы не ловить всё подряд catch (\Exception $e) и не глушить настоящие баги, а выстраивать свои исключения под бизнес-логику.
Зачем это нужно
Боль из практики: обработчик платежей «ловил всё» одним блоком — и выглядел аккуратно:
php// ПЛОХО: один catch на все сценарии
try {
$payment = $gateway->pay($order->total);
} catch (\Exception $e) {
Log::error($e);
return back()->with('error', 'Ошибка');
}
Потом платежи стали «теряться»: пользователь платит, а заказ не оформляется. Причина — один общий catch не различает два разных сценария:
php// Реально были два разных исключения с разными последствиями:
// 1. Шлюз не списал деньги → можно повторить попытку
class PaymentDeclinedException extends \Exception {}
// 2. Деньги списаны, но подтверждение не долетело → повторять НЕЛЬЗЯ, заказ надо фиксировать
class PaymentConfirmationLostException extends \Exception {}
php// ПЛОХО в действии: после списания денег подтверждение потерялось,
// но код вернул пользователю «Ошибка» — и он платит второй раз
try {
$gateway->pay($order->total); // деньги ушли, вебхук не долетел
$order->markPaid(); // до этой строки не дошло
} catch (\Exception $e) {
Log::error($e); // лог есть, заказа нет
return back()->with('error', 'Ошибка');
}
Ошибка не в обработчике, а в конструкции: код, который ловит всё, обязан разбираться со всем — а не умеет. Исправление — ловить по сценариям:
php// ХОРОШО: каждое исключение — своя реакция
try {
$gateway->pay($order->total);
$order->markPaid();
} catch (PaymentDeclinedException $e) {
return back()->with('error', 'Платёж не прошёл, попробуйте снова');
} catch (PaymentConfirmationLostException $e) {
$order->markAwaitingConfirmation(); // деньги списаны — дожидаемся вебхука, не даём платить дважды
return back()->with('info', 'Платёж обрабатывается');
}
Как это работает
Иерархия ошибок PHP с версии 7.0:
textThrowable (интерфейс — ловить можно только его наследников) ├── Error — фатальные ошибки движка │ ├── TypeError — неправильный тип аргумента/возврата │ ├── DivisionByZeroError │ ├── ArithmeticError │ └── AssertionError └── Exception — базовый класс для «обычных» ошибок ├── RuntimeException — ошибка в рантайме (сеть, файлы) │ └── PDOException — ошибки базы данных ├── LogicException — ошибка в логике кода (ловим в dev, не в prod) │ ├── InvalidArgument...Exception — наследники с 8.0 │ └── OutOfRangeException └── \Exception от библиотек (Stripe, Guzzle, ...)
Базовый механизм:
phptry {
$payment = $gateway->pay($order->total);
$order->markPaid($payment);
} catch (PaymentFailedException $e) {
// конкретная ошибка — конкретная реакция
Log::error('Платёж не прошёл', ['order' => $order->id]);
return back()->with('error', 'Платёж не прошёл, попробуйте снова');
} catch (Throwable $e) {
// всё остальное — честно наверх, пусть увидит обработчик Laravel
throw $e;
} finally {
// выполнится всегда: и при исключении, и без него
Metrics::record('checkout_attempt');
}
Порядок catch важен: PHP проверяет сверху вниз, поэтому частные исключения ставим выше общих. finally — для гарантированной уборки, не для реакции на ошибку.
Свои исключения: иерархия под домен
Смысл своих классов — не красивые имена, а разные реакции на разные сценарии:
php// Общий корень домена — ловим «любую проблему заказов» одним блоком
abstract class OrderException extends \Exception {}
// Частные — по сценариям
final class OrderAlreadyShipped extends OrderException {}
final class InsufficientStock extends OrderException {}
// Использование
class OrderService
{
public function ship(Order $order): void
{
if ($order->isShipped()) {
throw OrderAlreadyShipped::forOrder($order); // фабричный метод с понятным сообщением
}
// ...
}
}
try {
$service->ship($order);
} catch (OrderAlreadyShipped $e) {
return response()->json(['message' => 'Заказ уже отправлен'], 409);
} catch (OrderException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
Правило наращивания: иерархия строится от реакций, а не от «красивых имён» — сначала вопрос «что мы делаем по-разному», потом классы.
Как это выглядит в Laravel
Глобальный обработчик — app/Exceptions/Handler.php (в Laravel 11 — файл bootstrap/app.php):
php// Регистрация: «такое исключение — такой ответ»
$app->renderable(OrderAlreadyShipped::class, fn ($e, $request) =>
response()->json(['message' => 'Заказ уже отправлен'], 409)
);
// report() — что писать в лог, render() — что показать пользователю
// Для своих исключений удобнее интерфейсы в самом классе:
class InsufficientStock extends OrderException
{
public function render($request)
{
return response()->json([
'message' => 'Недостаточно товара на складе',
], 422);
}
}
Laravel уже различает типы за вас: ModelNotFoundException → 404, ValidationException → 422 с ошибками полей, AuthenticationException → 401. Это пример хорошей иерархии — он же подсказывает шаблон для ваших классов.
Плохо / Хорошо
| Плохо | Хорошо |
|---|---|
catch (\Exception $e) {} — проглотить и забыть | Логируем или пробрасываем: ошибку нельзя молча съесть |
Один общий catch на всё | Частные исключения — частные реакции |
catch (Throwable $e) в контроллере «на всякий случай» | Глобальный Handler — в одном месте приложения |
Бросать \Exception('ошибка') | Свои классы с фабричными методами и кодами |
Исключение как return false + флаги | Исключение вместо магических значений |
Подводный камень
catch (\Exception)не ловитError.TypeError, вызов метода наnull— этоError, а неException. Пишетеcatch (\Exception $e)— и половина реальных ошибок пролетает мимо в фатал. Если ловите всё — ловитеThrowable.- Исключения внутри
finally. Если вfinallyвыбросить исключение, оно перебьёт исходное — и вы потеряете настоящую ошибку. Вfinally— только уборка: закрыть соединение, освободить ресурс. - Исключения в очередях и воркерах. В Laravel очередь сама репортит неудачные джобы — если написать в сервисе
catch (Throwable) { return; }, «ошибка» исчезнет, джоб отработает успешно, а данные останутся несогласованными. Не глушите исключения там, где их ждёт механизм ретраев. - Бросать и логировать одновременно.
throw+Log::error()в одном месте — ошибка окажется в логе дважды: и здесь, и в Handler'е. Решение — выбросить и доверить логирование обработчику верхнего уровня.
Мини-шпаргалка
phpthrow InsufficientStock::forProduct($product); // своё исключение
try {
$service->ship($order);
} catch (OrderAlreadyShipped $e) { /* частная реакция */ }
catch (OrderException $e) { /* общая реакция домена */ }
// В Handler: иерархия вместо лапши из catch
$app->renderable(OrderAlreadyShipped::class, fn ($e) => response(409));
// Проверка: что поймали
get_parent_class($e); // на каком уровне иерархии
$e instanceof Throwable; // ловится ли вообще
Три вывода
- Мир ошибок PHP —
Throwableс двумя ветками:Error(движок) иException(код и библиотеки);catch (\Exception)не ловит фатальные ошибки — всё ловить умеет толькоThrowable. - Свои исключения строятся от реакций: частный класс — частная обработка, общий корень — общий
catchна весь домен. - Один общий
catch, который глотает всё, превращает любые ошибки в молчаливую порчу данных — ошибка должна либо логироваться, либо наверх.
Вопрос-ловушка
Спрашивают так: «Чем Exception отличается от Error?» Ждут не «это разные классы», а понимания деления: Error — ошибки движка и типов, которые обычно означают баг в коде и не подлежат восстановлению, Exception — предсказуемые ситуации, на которые код реагирует. Хитрый поворот: «Как одним catch поймать и то, и другое?» — через Throwable. И ещё один: «catch (\Exception $e) поймает TypeError?» — нет, это сигнал, что человек знает название интерфейса Throwable, но не помнит, кто его наследует.