Исключения и их иерархия в 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:

text
Throwable (интерфейс — ловить можно только его наследников)
├── Error                    — фатальные ошибки движка
│   ├── TypeError            — неправильный тип аргумента/возврата
│   ├── DivisionByZeroError
│   ├── ArithmeticError
│   └── AssertionError
└── Exception                — базовый класс для «обычных» ошибок
    ├── RuntimeException     — ошибка в рантайме (сеть, файлы)
    │   └── PDOException     — ошибки базы данных
    ├── LogicException       — ошибка в логике кода (ловим в dev, не в prod)
    │   ├── InvalidArgument...Exception — наследники с 8.0
    │   └── OutOfRangeException
    └── \Exception от библиотек (Stripe, Guzzle, ...)

Базовый механизм:

php
try {
    $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 + флагиИсключение вместо магических значений

Подводный камень

  1. catch (\Exception) не ловит Error. TypeError, вызов метода на null — это Error, а не Exception. Пишете catch (\Exception $e) — и половина реальных ошибок пролетает мимо в фатал. Если ловите всё — ловите Throwable.
  2. Исключения внутри finally. Если в finally выбросить исключение, оно перебьёт исходное — и вы потеряете настоящую ошибку. В finally — только уборка: закрыть соединение, освободить ресурс.
  3. Исключения в очередях и воркерах. В Laravel очередь сама репортит неудачные джобы — если написать в сервисе catch (Throwable) { return; }, «ошибка» исчезнет, джоб отработает успешно, а данные останутся несогласованными. Не глушите исключения там, где их ждёт механизм ретраев.
  4. Бросать и логировать одновременно. throw + Log::error() в одном месте — ошибка окажется в логе дважды: и здесь, и в Handler'е. Решение — выбросить и доверить логирование обработчику верхнего уровня.

Мини-шпаргалка

php
throw 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;      // ловится ли вообще

Три вывода

  1. Мир ошибок PHP — Throwable с двумя ветками: Error (движок) и Exception (код и библиотеки); catch (\Exception) не ловит фатальные ошибки — всё ловить умеет только Throwable.
  2. Свои исключения строятся от реакций: частный класс — частная обработка, общий корень — общий catch на весь домен.
  3. Один общий catch, который глотает всё, превращает любые ошибки в молчаливую порчу данных — ошибка должна либо логироваться, либо наверх.

Вопрос-ловушка

Спрашивают так: «Чем Exception отличается от Error?» Ждут не «это разные классы», а понимания деления: Error — ошибки движка и типов, которые обычно означают баг в коде и не подлежат восстановлению, Exception — предсказуемые ситуации, на которые код реагирует. Хитрый поворот: «Как одним catch поймать и то, и другое?» — через Throwable. И ещё один: «catch (\Exception $e) поймает TypeError?» — нет, это сигнал, что человек знает название интерфейса Throwable, но не помнит, кто его наследует.