Вариант обработки исключений в Laravel 5.1

26 августа jhaoda

По мотивам вопроса в чате...

Внимание! Это не обучающая статья, раскрывающая самые базовые принципы работы с исключениями. Предполагается, что читатель знаком с исключениями и их обработкой в целом, а так же внимательно ознакомился с разделом Errors & Logging официальной документации.

Основной обработчик исключений

Вся работа с исключения происходит в файле app/Exceptions/Handler.php, в котором есть два метода — report(), отвечающий за логирование исключения и render(), отвечающий за формирование представления, а так же массив $dontReport, содержащий имена классов исключений, которые логировать не надо.

Начнём с того, что дополним $dontReport:

protected $dontReport = [
    \Illuminate\Session\TokenMismatchException::class,
    \Symfony\Component\HttpKernel\Exception\HttpException::class
];

Т.е. мы не хотим логировать всякие ошибки типа 404 и невалидные CSRF-токены.

Теперь изменим код метода render():

public function render($request, \Exception $e)
{
    $statusCode = $this->getStatusCode($e);

    if ($request->wantsJson()) {
        return response()->json(['message' => $this->getMessage($e)], $statusCode);
    }

    // в режиме отладки выводим все ошибки как есть
    if (config('app.debug')) {
        return parent::render($request, $e);
    }

    // если это потомок \Symfony\Component\HttpKernel\Exception\HttpException
    if ($this->isHttpException($e)) {
        return $this->renderHttpException($e);
    }

    // иначе показываем стандартную страницу ошибки
    return response()->view('errors.500', [], 500);
}

Почему wantsJson(), а не ajax()? Потому что ajax это не обязательно ответ в json'е, плюс некоторые клиентские библиотеки не устанавливают заголовок X-Requested-With: XMLHttpRequest. В любом случае, вы всегда можете изменить условие проверки.

Добавим недостающие методы:

protected function getStatusCode(\Exception $e)
{
    if ($e instanceof HttpException) {
        return $e->getStatusCode();
    }

    // данное исключение не является потомком \Symfony\Component\HttpKernel\Exception\HttpException,
    // поэтому небольшой хак
    if ($e instanceof ModelNotFoundException) {
        return 404;
    }

    return 500;
}

protected function getMessage(\Exception $e)
{
    // это исключение я создал сам и использую в моделях,
    // у него человекопонятные сообщения типа «Не удалось сохранить запись»
    if ($e instanceof DatabaseException) {
        return $e->getMessage();
    }

    if ($e instanceof ModelNotFoundException) {
        return trans('main.model_not_found');
    }

    return trans('main.something_wrong');
}

Метод getMessage() нужен затем, что не следует показывать пользователю «сырое» сообщение из исключения — мало ли какая секретная информация там окажется.

Исключение при проверке CSRF-токена

Теперь рассмотрим одну из самых частых ситуаций, когда при очередном запросе сервер возвращает исключение TokenMismatchException.

TokenMismatchException говорит о том, что присланный клиентом токен не совпадает с имеющимся в сессии токеном. Причина этому одна — старая сессия «протухла» и при очередном запросе началась новая.

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

Но что делать, если пользователь начал заполнять какую-то форму в админке, потом на два дня ушел за хлебом, вернулся и нажал кнопку «Сохранить»?

В посреднике VerifyCsrfToken модифицируем метод handle:

    public function handle($request, Closure $next)
    {
        try {
            return parent::handle($request, $next);
        } catch (TokenMismatchException $e) {
            if ($request->wantsJson()) {
                return response()->json(['message' => 'Надо залогиниться'], 418);
            }

            return redirect()->route('auth.login.show');
        }
    }

Т.е. если это был ajax-запрос, мы вернем нормальный json-ответ. Код 418 выбран для удобства, что бы было проще отслеживать это событие на клиенте. При получении такого ответа клиент может сохранить данные формы в localStorage, перенаправить пользователя на страницу логина и т.д.

Можно дополнительно выбрасывать ещё одно исключение, чтобы логировать факт «протухания» сессии/токена.

Данный вариант не является универсальным, при большом количестве исключений, требующих особой обработки или условий, меняющих поведение обработчика, метод render() может превратиться в «расчёску» из if...else...endif и иных конструкций.