Иллюстрация к статье про исключения в PHP и Laravel: обработка ошибок, бизнес-логика, архитектурные слои, API-ответы и безопасное логирование.

Исключения в PHP и Laravel: что это, зачем нужны и как применять

Исключения в PHP часто воспринимают слишком узко: как способ “поймать ошибку” через try/catch. Из-за этого в проектах появляются две крайности.

Первая — исключения почти не используют. Методы возвращают false, null, пустой массив или строку с ошибкой, а вызывающий код должен гадать, что именно произошло.

Вторая — исключения используют везде подряд: вместо обычных if, вместо результата операции, вместо валидации, вместо нормальной модели состояния. В итоге код начинает прыгать по throw/catch как по скрытым goto. Да, PHP такого не заслужил. Хотя местами сам напрашивался.

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

Например:

  • пользователь пытается оплатить уже оплаченный заказ;
  • внешний платёжный сервис недоступен;
  • файл не найден;
  • у пользователя нет прав на действие;
  • нарушено бизнес-правило;
  • приложение оказалось в состоянии, в котором продолжать операцию опасно.

Исключения — это не просто технический механизм PHP. В большом приложении это архитектурный инструмент: он помогает отделить нормальный сценарий от невозможного, сохранить контекст ошибки, выполнить rollback, запустить retry в очереди и вернуть пользователю понятный ответ.

Содержание
  1. Коротко
  2. Что такое исключение в PHP
  3. Зачем нужны исключения
  4. Исключения — не замена обычным if
  5. Базовые типы исключений в архитектуре
  6. Доменные исключения
  7. Application exceptions
  8. Инфраструктурные исключения
  9. HTTP и validation exceptions
  10. Как Laravel обрабатывает исключения
  11. Domain exception → HTTP response
  12. Где бросать исключения
  13. Controller
  14. Domain layer
  15. Application layer
  16. Infrastructure layer
  17. Где ловить исключения
  18. Custom exceptions: когда нужны свои классы
  19. Сообщение исключения: для разработчика или пользователя
  20. Исключения и логирование
  21. Исключения в API
  22. Исключения и транзакции
  23. Исключения в очередях
  24. Исключения и внешние API
  25. Result object против exceptions
  26. Частые ошибки при работе с исключениями
  27. 1. ЛовитьThrowableвезде подряд
  28. 2. Бросать простоException
  29. 3. Использовать exception как обычный if
  30. 4. Возвращать пользователю техническое сообщение
  31. 5. Гасить исключения в очередях
  32. 6. Логировать ожидаемые ошибки как critical
  33. 7. Делать domain exception зависимым от HTTP
  34. 8. Терять previous exception
  35. 9. Создавать слишком много кастомных исключений
  36. 10. Возвращатьnullвместо явной ошибки
  37. Практический чек-лист
  38. Итог

Коротко

Если сжать статью до нескольких правил:

  • исключение нужно, когда сценарий нельзя корректно продолжить;
  • обычные ветки поведения лучше выражать через if, статус, Result или явную модель результата;
  • Throwable — общий интерфейс для Exception и Error;
  • Exception — базовый класс для пользовательских исключений;
  • Error — ошибки уровня языка и движка;
  • в Laravel необработанные исключения превращаются в HTTP-ответы;
  • доменные исключения должны описывать бизнес-проблему, а не HTTP-код;
  • инфраструктурные исключения должны изолировать детали внешних сервисов;
  • ловить исключение нужно только там, где можно принять осмысленное решение;
  • нельзя гасить исключения в очередях и транзакциях просто “чтобы не падало”.

Главная мысль:

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

Что такое исключение в PHP

Исключение — это объект, который можно выбросить через throw и перехватить через try/catch.

Минимальный пример:

try {
    $paymentService->charge(
        orderId: $orderId,
    );
} catch (Throwable $exception) {
    report($exception);

    throw $exception;
}

В PHP есть несколько важных понятий:

  • Throwable;
  • Exception;
  • Error;
  • try;
  • catch;
  • finally;
  • throw.

Throwable — общий интерфейс для всего, что можно выбросить через throw. Его реализуют Exception и Error.

Exception — базовый класс для обычных пользовательских исключений.

Error — ошибки уровня языка: вызов несуществующей функции, ошибка типов, обращение к неинициализированному свойству и похожие проблемы.

Пример:

try {
    $user = $userRepository->getById(
        userId: $userId,
    );
} catch (UserNotFoundException $exception) {
    // Конкретная обработка пользователя, который не найден.
}

catch должен иметь смысл. Если код ловит исключение и ничего с ним не делает, это не обработка ошибки. Это сокрытие проблемы.

Плохой пример:

try {
    $paymentService->charge(
        orderId: $orderId,
    );
} catch (Throwable $exception) {
    // Молча проглотили ошибку.
}

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

Зачем нужны исключения

Исключения решают несколько задач.

Они позволяют:

  • остановить некорректный сценарий;
  • передать ошибку выше по стеку;
  • сохранить контекст ошибки;
  • отделить happy path от error path;
  • не возвращать false, null или пустой массив там, где произошла ошибка;
  • сделать причину ошибки явной через тип исключения.

Плохой вариант:

class Wallet
{
    public function withdraw(int $amount): bool
    {
        if ($amount <= 0) {
            return false;
        }

        if ($amount > $this->balance) {
            return false;
        }

        $this->balance -= $amount;

        return true;
    }
}

Вызвавший код получает false, но не знает причину:

  • сумма отрицательная;
  • денег недостаточно;
  • кошелёк заблокирован;
  • произошла техническая ошибка;
  • разработчик просто забыл вернуть true.

Лучше:

class Wallet
{
    public function withdraw(int $amount): void
    {
        if ($amount <= 0) {
            throw new InvalidWithdrawalAmountException();
        }

        if ($amount > $this->balance) {
            throw new NotEnoughBalanceException();
        }

        $this->balance -= $amount;
    }
}

Теперь ошибка выражена явно:

  • InvalidWithdrawalAmountException — некорректная сумма;
  • NotEnoughBalanceException — недостаточно средств.

Тип исключения становится частью языка приложения.

Исключения — не замена обычным if

Исключения не нужны для каждой альтернативной ветки.

Плохо:

try {
    $discount = $user->getPersonalDiscount();
} catch (DiscountNotFoundException) {
    $discount = 0;
}

Если отсутствие скидки — обычная ситуация, исключение здесь лишнее.

Лучше:

$discount = $user->personalDiscount() ?? 0;

Или так:

if ($user->hasPersonalDiscount()) {
    $discount = $user->personalDiscount();
} else {
    $discount = 0;
}

Исключение уместно, когда операция не может продолжаться нормально.

Обычная ветка поведения:

  • у пользователя нет скидки;
  • список заказов пуст;
  • фильтр не выбран;
  • опциональная настройка не задана;
  • рекомендация недоступна.

Исключительный сценарий:

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

Правило:

Исключения нужны для невозможных сценариев, а не для всех вариантов поведения.

Базовые типы исключений в архитектуре

В большом приложении полезно разделять исключения по смыслу.

Обычно хватает четырёх групп:

  • доменные исключения;
  • application exceptions;
  • инфраструктурные исключения;
  • HTTP / validation exceptions.

Доменные исключения

Доменные исключения описывают нарушение бизнес-правил.

Примеры:

  • заказ уже оплачен;
  • нельзя отменить отправленный заказ;
  • недостаточно средств;
  • пользователь не может выполнить действие;
  • сумма платежа некорректна;
  • тариф нельзя удалить, потому что он используется.

Пример:

class OrderAlreadyPaidException extends DomainException
{
}

Использование:

class Order
{
    public function pay(): void
    {
        if ($this->status->isPaid()) {
            throw new OrderAlreadyPaidException();
        }

        $this->status = OrderStatus::paid();
    }
}

Такое исключение не должно знать про HTTP, Laravel response, JSON или статус 409 Conflict.

Домен говорит только одно: заказ уже оплачен, операция невозможна.

Где может жить такое исключение:

app/
└── Modules/
    └── Order/
        └── Domain/
            └── Exceptions/
                └── OrderAlreadyPaidException.php

Application exceptions

Application exceptions относятся к сценарию приложения.

Примеры:

  • use case не может быть выполнен;
  • команда не прошла предварительные условия;
  • операция запрещена в текущем application-контексте;
  • нужные данные не найдены для выполнения сценария.

Пример:

class CreateInvoiceFailedException extends RuntimeException
{
}

Такие исключения находятся выше домена. Они описывают не само бизнес-правило, а невозможность выполнить сценарий приложения.

Пример:

class CreateInvoiceAction
{
    public function handle(OrderId $orderId): Invoice
    {
        $order = $this->orderRepository->find(
            orderId: $orderId,
        );

        if ($order === null) {
            throw new CreateInvoiceFailedException(
                message: 'Order for invoice creation was not found.',
            );
        }

        // ...
    }
}

Здесь важно не превращать application exceptions в мусорную корзину. Если ошибка является бизнес-правилом, ей место в domain exception. Если ошибка техническая, ей место в infrastructure exception.

Инфраструктурные исключения

Инфраструктурные исключения описывают проблемы внешнего мира.

Примеры:

  • платёжный API недоступен;
  • Redis не отвечает;
  • файл не найден;
  • HTTP timeout;
  • внешний сервис вернул неверный JSON;
  • невозможно записать файл;
  • очередь недоступна.

Пример:

class PaymentGatewayUnavailableException extends RuntimeException
{
}

Использование:

class PaymentGatewayClient
{
    public function charge(PaymentRequestData $paymentRequestData): PaymentResponseData
    {
        try {
            $response = $this->httpClient->post(
                uri: '/payments',
                options: [
                    'json' => [
                        'amount' => $paymentRequestData->amount,
                        'currency' => $paymentRequestData->currency,
                    ],
                ],
            );
        } catch (Throwable $exception) {
            throw new PaymentGatewayUnavailableException(
                message: 'Payment gateway is unavailable.',
                previous: $exception,
            );
        }

        // ...
    }
}

Здесь важны две вещи:

  • приложение не тащит Guzzle/Curl/HTTP-client исключение через все слои;
  • исходная ошибка сохраняется в previous.

previous нужен, чтобы в логах был полный контекст исходной технической проблемы.

HTTP и validation exceptions

Laravel уже содержит много готовых исключений для HTTP-уровня.

Типичные примеры:

ИсключениеHTTP-кодСмысл
ValidationException422Ошибка входных данных
AuthenticationException401Пользователь не авторизован
AuthorizationException403Нет прав
ModelNotFoundException404Модель не найдена
NotFoundHttpException404Маршрут или ресурс не найден
unexpected Throwable500Внутренняя ошибка

Эти исключения относятся к границе приложения: HTTP-запросу, авторизации, валидации и маршрутизации.

Не надо превращать доменный слой в HTTP-слой.

Плохо:

class Order
{
    public function pay(): void
    {
        if ($this->status->isPaid()) {
            abort(409, 'Order already paid.');
        }
    }
}

Домен не должен вызывать abort().

Лучше:

class Order
{
    public function pay(): void
    {
        if ($this->status->isPaid()) {
            throw new OrderAlreadyPaidException();
        }
    }
}

А HTTP-слой уже решит, какой response вернуть.

Как Laravel обрабатывает исключения

В Laravel исключение может быть выброшено в контроллере, action, модели, middleware, job или любом другом месте приложения.

Если исключение не поймано вручную, Laravel поднимает его до глобального обработчика.

Дальше Laravel:

  • определяет, нужно ли сообщать об исключении;
  • логирует исключение или передаёт его в error tracking;
  • превращает исключение в HTTP-ответ;
  • для web-запроса может вернуть HTML-страницу ошибки;
  • для API-запроса может вернуть JSON.

В современных версиях Laravel настройка обработки исключений обычно находится в bootstrap/app.php через withExceptions().

Пример:

use App\Modules\Order\Domain\Exceptions\OrderAlreadyPaidException;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (
        OrderAlreadyPaidException $exception,
        Request $request,
    ) {
        return response()->json([
            'message' => 'Order already paid.',
            'code' => 'order_already_paid',
        ], 409);
    });
})

В старых Laravel-проектах для этого чаще использовался app/Exceptions/Handler.php.

Смысл один: приложение централизованно решает, как конкретное исключение должно быть залогировано и превращено в response.

Domain exception → HTTP response

Важно удержать границу.

Domain exception:

class OrderAlreadyPaidException extends DomainException
{
}

Domain layer не знает, что это 409 Conflict.

Laravel-слой знает:

use App\Modules\Order\Domain\Exceptions\OrderAlreadyPaidException;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (
        OrderAlreadyPaidException $exception,
        Request $request,
    ) {
        return response()->json([
            'message' => 'Order already paid.',
            'code' => 'order_already_paid',
        ], 409);
    });
})

Так архитектура остаётся чистой:

  • домен сообщает о бизнес-проблеме;
  • Laravel превращает её в HTTP-ответ;
  • API получает единый формат ошибки;
  • frontend может обработать code.

Где бросать исключения

Исключение нужно бросать там, где обнаружена невозможность продолжить сценарий.

По слоям это выглядит так.

Controller

Контроллер не должен быть местом сложных бизнес-проверок.

Плохо:

class PaymentController
{
    public function pay(Request $request): JsonResponse
    {
        $order = Order::query()->findOrFail($request->integer('order_id'));

        if ($order->status === 'paid') {
            throw new Exception('Order already paid.');
        }

        // ...
    }
}

Контроллер начал знать бизнес-правило оплаты.

Лучше:

class PaymentController
{
    public function pay(PayOrderRequest $request): JsonResponse
    {
        $this->payOrderAction->handle(
            orderId: new OrderId(
                value: $request->integer('order_id'),
            ),
        );

        return response()->json([
            'message' => 'Order paid.',
        ]);
    }
}

Проверку выполнит application/domain слой.

Domain layer

В домене уместны исключения бизнес-правил.

class Order
{
    public function pay(): void
    {
        if ($this->status->isPaid()) {
            throw new OrderAlreadyPaidException();
        }

        if ($this->status->isCancelled()) {
            throw new CancelledOrderCannotBePaidException();
        }

        $this->status = OrderStatus::paid();
    }
}

Здесь исключения хорошо читаются: они говорят языком домена.

Application layer

Application layer координирует сценарий.

class PayOrderAction
{
    public function handle(OrderId $orderId): void
    {
        $order = $this->orderRepository->get(
            orderId: $orderId,
        );

        $order->pay();

        $this->orderRepository->save(
            order: $order,
        );
    }
}

Если pay() бросит доменное исключение, action не обязан его ловить. Пусть исключение поднимется выше, где Laravel превратит его в ответ.

Application layer ловит исключения только тогда, когда может сделать что-то осмысленное:

  • повторить операцию;
  • переключить состояние;
  • обернуть infrastructure exception;
  • запустить компенсационное действие;
  • отправить задачу в failed state.

Infrastructure layer

В инфраструктуре уместны технические исключения.

class InvoiceFileStorage
{
    public function save(InvoiceFile $invoiceFile): void
    {
        try {
            $this->filesystem->put(
                path: $invoiceFile->path,
                contents: $invoiceFile->contents,
            );
        } catch (Throwable $exception) {
            throw new InvoiceFileSaveFailedException(
                message: 'Invoice file could not be saved.',
                previous: $exception,
            );
        }
    }
}

Так приложение не зависит от конкретного исключения filesystem-библиотеки.

Где ловить исключения

Главное правило:

Ловить исключение нужно там, где можно принять осмысленное решение.

Плохой catch:

try {
    $service->handle();
} catch (Throwable $exception) {
    return null;
}

Почему плохо:

  • ошибка скрыта;
  • контекст потерян;
  • вызывающий код получает null без причины;
  • система продолжает работать в неизвестном состоянии.

Хорошие причины ловить исключение:

  • залогировать с дополнительным контекстом;
  • повторить операцию;
  • вернуть понятный HTTP-ответ;
  • выполнить cleanup;
  • заменить техническую ошибку на application-level exception;
  • пометить задачу как failed;
  • отправить метрику или событие мониторинга;
  • выполнить компенсационное действие.

Пример осмысленного catch:

class PaymentService
{
    public function charge(Order $order): void
    {
        try {
            $this->paymentGatewayClient->charge(
                paymentRequestData: PaymentRequestData::fromOrder(
                    order: $order,
                ),
            );
        } catch (PaymentGatewayUnavailableException $exception) {
            $this->logger->error('Payment gateway unavailable.', [
                'orderId' => $order->id->value,
                'exception' => $exception,
            ]);

            throw $exception;
        }
    }
}

Здесь catch добавляет контекст и не гасит ошибку.

Custom exceptions: когда нужны свои классы

Свои исключения нужны не всегда.

Они полезны, если:

  • ошибку нужно отличать по типу;
  • ошибка относится к бизнес-правилу;
  • нужно отдельное HTTP-представление;
  • нужно отдельно логировать или не логировать;
  • нужно ловить конкретный сценарий;
  • исключение является частью языка домена.

Хороший пример:

class NotEnoughBalanceException extends DomainException
{
}

Использование:

if ($amount->greaterThan($this->balance)) {
    throw new NotEnoughBalanceException();
}

Плохой пример:

UserNameIsEmptyException
UserEmailIsEmptyException
UserPhoneIsEmptyException

Для пустых полей обычно есть validation.

Если каждое поле формы превращается в отдельный exception-класс, архитектура начинает изображать из себя бюрократический аппарат. С печатью, папкой и ответственным за скрепки.

Сообщение исключения: для разработчика или пользователя

Сообщение исключения часто пишется для разработчика, логов и диагностики.

Пользователю нельзя всегда показывать его напрямую.

Плохо:

throw new RuntimeException(
    message: 'SQLSTATE[HY000] [2002] Connection refused at 10.0.0.12',
);

И потом вернуть это в API.

Пользователь не должен видеть:

  • SQL errors;
  • внутренние IP;
  • названия таблиц;
  • stack trace;
  • API keys;
  • технические детали провайдера;
  • персональные данные других пользователей.

Для пользователя нужен безопасный ответ:

{
  "message": "Payment service is temporarily unavailable.",
  "code": "payment_service_unavailable"
}

Для логов нужен полный контекст.

Разделяйте:

  • developer-facing message;
  • user-facing message;
  • machine-readable error code.

Исключения и логирование

Логировать нужно не всё одинаково.

Что полезно логировать:

  • тип исключения;
  • сообщение;
  • trace;
  • user id;
  • order id;
  • request id;
  • внешний сервис;
  • HTTP status внешнего API;
  • безопасный payload;
  • correlation id.

Что нельзя логировать:

  • пароли;
  • access tokens;
  • refresh tokens;
  • банковские карты;
  • секреты API;
  • приватные персональные данные без необходимости.

Разделяйте ожидаемые и неожиданные исключения.

Например:

  • OrderAlreadyPaidException — ожидаемое бизнес-исключение, часто это не error;
  • PaymentGatewayUnavailableException — инфраструктурная проблема, это может быть error;
  • TypeError в production — вероятный баг, это уже серьёзнее.

Не каждое исключение заслуживает critical. Иначе через месяц команда перестанет читать логи. Всё будет гореть красным, как новогодняя ёлка, только пользы меньше.

Исключения в API

Для API важен единый формат ошибки.

Хороший ответ содержит:

  • message;
  • code;
  • errors, если это validation;
  • понятный HTTP status;
  • иногда request_id, если есть трассировка.

Пример доменной ошибки:

{
  "message": "Order already paid.",
  "code": "order_already_paid"
}

Пример validation error:

{
  "message": "The given data was invalid.",
  "errors": {
    "email": [
      "The email field is required."
    ]
  }
}

Для frontend и API-клиентов code часто полезнее текста.

Текст может поменяться. Код ошибки должен быть стабильным:

order_already_paid
not_enough_balance
payment_service_unavailable
subscription_expired

Исключения и транзакции

Исключения хорошо сочетаются с транзакциями.

В Laravel, если внутри DB::transaction() выброшено исключение, транзакция откатывается.

Пример:

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($orderId): void {
    $order = $this->orderRepository->get(
        orderId: $orderId,
    );

    $order->pay();

    $this->orderRepository->save(
        order: $order,
    );
});

Если pay() бросит OrderAlreadyPaidException, сохранение не продолжится, а транзакция будет откатана.

Опасный вариант:

DB::transaction(function () use ($orderId): void {
    try {
        $order = $this->orderRepository->get(
            orderId: $orderId,
        );

        $order->pay();

        $this->orderRepository->save(
            order: $order,
        );
    } catch (Throwable $exception) {
        // Ошибку проглотили внутри транзакции.
    }
});

Так делать нельзя, если ошибка должна остановить операцию. Если исключение поймано и не проброшено дальше, транзакция может завершиться как успешная, хотя сценарий был сломан.

Правило:

Внутри транзакции не гасите исключение, если операция должна быть отменена.

Исключения в очередях

В Laravel job может выбросить исключение. Это нормально.

Если job падает, Laravel может:

  • повторить выполнение;
  • применить backoff;
  • исчерпать количество попыток;
  • отправить job в failed jobs;
  • вызвать failed()-метод, если он определён.

Плохой пример:

class ChargePaymentJob
{
    public function handle(): void
    {
        try {
            $this->paymentGatewayClient->charge(
                paymentId: $this->paymentId,
            );
        } catch (Throwable $exception) {
            // Ничего не делаем.
        }
    }
}

Job завершится “успешно”, хотя платёж не был выполнен.

Лучше:

class ChargePaymentJob
{
    public function handle(): void
    {
        $this->paymentGatewayClient->charge(
            paymentId: $this->paymentId,
        );
    }

    public function failed(Throwable $exception): void
    {
        report($exception);
    }
}

Если провайдер временно недоступен, пусть job упадёт и будет повторён по настройкам очереди.

Можно явно задать попытки и backoff:

class ChargePaymentJob
{
    public int $tries = 3;

    public function backoff(): array
    {
        return [60, 300, 900];
    }

    public function handle(): void
    {
        $this->paymentGatewayClient->charge(
            paymentId: $this->paymentId,
        );
    }
}

Правило:

Не гасите исключения в очередях, если хотите, чтобы retry и failed jobs работали.

Исключения и внешние API

Внешние API почти всегда требуют отдельной обработки ошибок.

Что может пойти не так:

  • timeout;
  • 500 от провайдера;
  • 401 из-за токена;
  • 429 rate limit;
  • неверный JSON;
  • пустой ответ;
  • бизнес-ошибка от провайдера;
  • несовместимое изменение контракта.

Не стоит протаскивать исключения HTTP-клиента через всё приложение.

Плохо:

class CreatePaymentAction
{
    public function handle(CreatePaymentData $createPaymentData): void
    {
        $this->httpClient->post(
            uri: '/payments',
            options: [
                'json' => [
                    'amount' => $createPaymentData->amount,
                ],
            ],
        );
    }
}

Если HTTP-клиент выбросит своё исключение, application layer начнёт зависеть от деталей библиотеки.

Лучше:

class PaymentGatewayClient
{
    public function createPayment(CreatePaymentData $createPaymentData): PaymentData
    {
        try {
            $response = $this->httpClient->post(
                uri: '/payments',
                options: [
                    'json' => [
                        'amount' => $createPaymentData->amount,
                        'currency' => $createPaymentData->currency,
                    ],
                ],
            );
        } catch (Throwable $exception) {
            throw new PaymentGatewayUnavailableException(
                message: 'Payment gateway unavailable.',
                previous: $exception,
            );
        }

        return $this->paymentResponseMapper->fromResponse(
            response: $response,
        );
    }
}

Так приложение знает про PaymentGatewayUnavailableException, а не про внутренности HTTP-клиента.

Result object против exceptions

Не все отрицательные результаты должны быть исключениями.

Иногда лучше вернуть объект результата.

Пример:

class PromoCodeApplyResult
{
    public function __construct(
        public readonly bool $success,
        public readonly ?string $errorCode,
    ) {
    }
}

Использование:

$result = $promoCodeService->apply(
    promoCode: $promoCode,
    order: $order,
);

if (! $result->success) {
    return response()->json([
        'message' => 'Promo code cannot be applied.',
        'code' => $result->errorCode,
    ], 422);
}

Когда Result object лучше:

  • промокод не найден;
  • скидка не применима;
  • рекомендация недоступна;
  • проверка легально вернула “нет”;
  • отрицательный результат является частью нормального сценария.

Когда exception лучше:

  • сценарий не может продолжаться;
  • нарушен инвариант;
  • внешняя система недоступна;
  • состояние системы неожиданное;
  • нужен rollback;
  • нужен retry очереди.

Исключения и Result object не конкуренты. Это разные инструменты для разных ситуаций.

Частые ошибки при работе с исключениями

1. Ловить Throwable везде подряд

try {
    $service->handle();
} catch (Throwable) {
    return null;
}

Так код прячет баги.

2. Бросать просто Exception

throw new Exception('Error.');

Тип исключения ничего не говорит.

Лучше:

throw new OrderAlreadyPaidException();

3. Использовать exception как обычный if

Если результат ожидаемый, не всегда нужен exception.

4. Возвращать пользователю техническое сообщение

Технические детали — в лог. Пользователю — безопасный текст и код ошибки.

5. Гасить исключения в очередях

Так retry и failed jobs не сработают.

6. Логировать ожидаемые ошибки как critical

Если всё critical, то ничего не critical.

7. Делать domain exception зависимым от HTTP

Домен не должен знать про response(), abort(), JsonResponse и HTTP-коды.

8. Терять previous exception

Плохо:

throw new PaymentGatewayUnavailableException(
    message: 'Gateway unavailable.',
);

Лучше:

throw new PaymentGatewayUnavailableException(
    message: 'Gateway unavailable.',
    previous: $exception,
);

Иначе диагностика станет беднее.

9. Создавать слишком много кастомных исключений

Кастомное исключение должно иметь смысл. Не каждое поле формы заслуживает отдельный класс.

10. Возвращать null вместо явной ошибки

Если отсутствие значения является ошибкой, null только откладывает проблему.

Практический чек-лист

Исключение уместно, если:

  • сценарий нельзя корректно продолжить;
  • нарушено бизнес-правило;
  • данные находятся в невозможном состоянии;
  • внешняя система недоступна;
  • ошибку должен обработать верхний слой;
  • нужен rollback транзакции;
  • нужна retry-логика очереди;
  • важно различать ошибку по типу.

Исключение не нужно, если:

  • это обычная ветка поведения;
  • пользователь не выбрал опциональное поле;
  • результат “не найдено” ожидаем;
  • можно безопасно вернуть пустую коллекцию;
  • обычный if делает код проще;
  • Result object лучше выражает сценарий.

Перед созданием нового exception-класса задайте вопросы:

  • это действительно отдельный тип ошибки;
  • его нужно ловить отдельно;
  • для него нужен отдельный HTTP-ответ;
  • он выражает бизнес-правило;
  • он помогает логированию или диагностике;
  • без него код станет менее понятным.

Если ответы отрицательные, отдельный класс, скорее всего, не нужен.

Итог

Исключения в PHP и Laravel — это не просто способ “поймать ошибку”. Это механизм остановки невозможного сценария.

Хорошее исключение:

  • имеет понятный тип;
  • появляется на правильном уровне;
  • не скрывает исходную причину;
  • не зависит от чужого слоя;
  • помогает rollback, retry и логированию;
  • превращается в понятный ответ на границе приложения.

Плохое исключение:

  • заменяет обычный if;
  • гасится пустым catch;
  • возвращается пользователю как техническая ошибка;
  • создаётся без смысла;
  • смешивает домен, HTTP и инфраструктуру.

Главная мысль:

Исключение нужно тогда, когда система больше не может продолжать сценарий корректно. Всё остальное лучше выражать обычным кодом, статусами, проверками или объектами результата.

Рейтинг
( Пока оценок нет )
Максим Гречушников/ автор статьи
Мне 40 лет, и больше половины жизни я занимаюсь разработкой. За это время я работал с корпоративными системами, highload-проектами, интеграциями, платежными решениями, внутренними платформами и legacy-кодом, который пережил не одного разработчика. Некоторые системы, кажется, переживут и нас. Моя специализация — PHP, Laravel, backend-архитектура, DDD, тестирование, рефакторинг и техническое лидерство. Мне интересны задачи, где нужно соединить код, бизнес, данные, процессы и здравый смысл. Последние годы я много работаю с LLM и AI-инструментами: использую их для проектирования, анализа кода, документации, тестов, ревью, работы с legacy и построения инженерных процессов. Для меня LLM — это не замена разработчику, а усилитель для тех, кто умеет думать системно. В блоге я пишу о Laravel, PHP, архитектуре, тестировании, инженерной практике, управлении разработкой и реальном применении AI в работе программиста.
Понравилась статья? Поделиться с друзьями: