Laravel: Exceptions
Glossary overview

Laravel: Exceptions

Якщо у твоїх Laravel-контролерах try-catch зустрічається в кожному екшені — це сигнал, що щось пішло не так на рівні архітектури. Laravel має потужну систему обробки винятків, яка дозволяє писати тонкі контролери на 3-5 рядків і централізовано керувати тим, як помилки перетворюються у відповіді.

Розберу детально, як це правильно робиться у сучасному Laravel, які є патерни, і коли try-catch у контролері все ж виправданий.

Проблема: try-catch у кожному контролері

Типовий код, який можна побачити у багатьох проєктах:

public function show($id)
{
    try {
        $user = User::findOrFail($id);
        return response()->json($user);
    } catch (\Exception $e) {
        return response()->json([
            'message' => 'Something went wrong'
        ], 500);
    }
}

Виглядає безпечно — «обгорнули про всяк випадок». Але насправді цей код:

  • Ховає корисну інформаціюModelNotFoundException мав би бути 404, а тут він стає 500 з абстрактним «something went wrong»
  • Дублюється у 50+ контролерахбудь-яка зміна формату помилок означає рефакторити всі
  • Розбухає логіка — корисний код тоне у boilerplate
  • Маскує реальні баги — ловиться все підряд, включаючи помилки програміста, які мали б бути видимими

Як це працює у Laravel насправді

Laravel сам обробляє більшість стандартних винятків. Не треба нічого ловити вручну. Цей код:

public function show($id)
{
    return User::findOrFail($id);
}

Автоматично поверне 404 з JSON-відповіддю, якщо юзера не знайдено. Без жодного try-catch. Аналогічно Laravel сам обробляє:

  • ModelNotFoundException → 404
  • AuthenticationException → 401
  • AuthorizationException → 403
  • ValidationException → 422 з масивом помилок по полях
  • HttpException → відповідний статус-код
  • NotFoundHttpException → 404

Тобто 90% типових випадків покриті з коробки. Залишається тільки кастомізувати формат відповіді під свій API і додати власні бізнес-винятки.

Важливо: Laravel 11+ змінив місце конфігурації

Якщо ти бачиш в туторіалах файл app/Exceptions/Handler.php — це застаріла інформація для Laravel 10 і раніше. У Laravel 11+ цього файлу взагалі не існує. Вся конфігурація переїхала в один файл:

// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Auth\AuthenticationException;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(/* ... */)
    ->withExceptions(function (Exceptions $exceptions) {
        // Кастомний рендер для відсутніх моделей
        $exceptions->render(function (ModelNotFoundException $e, $request) {
            if ($request->expectsJson()) {
                return response()->json([
                    'message' => 'Resource not found',
                    'resource' => class_basename($e->getModel()),
                ], 404);
            }
        });

        // Уніфікована відповідь для неавторизованих
        $exceptions->render(function (AuthenticationException $e, $request) {
            if ($request->expectsJson()) {
                return response()->json([
                    'message' => 'Unauthenticated',
                ], 401);
            }
        });

        // Логування без зміни відповіді
        $exceptions->report(function (\Throwable $e) {
            // Sentry, Slack, кастомна логіка
        });
    })
    ->create();

Ключова деталь: якщо рендер-замикання повертає null (наприклад, коли запит не JSON) — Laravel передає виняток далі по ланцюжку, аж до дефолтного обробника.

Renderable Exceptions: найчистіший підхід

Замість того, щоб реєструвати всі рендери глобально у bootstrap/app.php, можна зробити так, щоб кожен виняток сам знав, як себе перетворити на HTTP-відповідь. Це патерн Renderable Exceptions, і він фактично є найчистішим способом обробки помилок у Laravel.

// app/Exceptions/InsufficientBalanceException.php
namespace App\Exceptions;

use Exception;
use Illuminate\Http\Request;

class InsufficientBalanceException extends Exception
{
    public function __construct(
        public readonly float $required,
        public readonly float $available,
    ) {
        parent::__construct('Insufficient balance');
    }

    public function render(Request $request)
    {
        return response()->json([
            'message' => 'Недостатньо коштів на балансі',
            'required' => $this->required,
            'available' => $this->available,
        ], 422);
    }

    public function report(): bool
    {
        // false — не логувати (це бізнес-кейс, не баг)
        return false;
    }
}

Тепер у сервісі чи контролері просто:

public function withdraw(WithdrawRequest $request)
{
    $user = $request->user();
    
    if ($request->amount > $user->balance) {
        throw new InsufficientBalanceException(
            required: $request->amount,
            available: $user->balance,
        );
    }

    $user->decrement('balance', $request->amount);
    
    return response()->json(['status' => 'ok']);
}

Жодного try-catch. Laravel автоматично викличе метод render() на самому винятку, коли той підніметься з контролера. Це дає величезну перевагу: логіка перетворення винятку на відповідь живе разом з винятком, а не розкидана по контролерах і глобальних обробниках.

Уніфікований формат помилок для API

Для серйозного API варто мати єдину структуру відповіді на помилки. Тоді фронтенд може написати один універсальний обробник замість того, щоб парсити різні формати під різні endpoint’и.

// app/Exceptions/ApiException.php
namespace App\Exceptions;

use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

abstract class ApiException extends Exception
{
    abstract public function statusCode(): int;
    abstract public function errorCode(): string;

    public function context(): array
    {
        return [];
    }

    public function render(Request $request): JsonResponse
    {
        return response()->json([
            'error' => [
                'code' => $this->errorCode(),
                'message' => $this->getMessage(),
                'context' => $this->context(),
            ],
        ], $this->statusCode());
    }
}

Конкретні бізнес-винятки наслідують базовий клас:

// app/Exceptions/OrderAlreadyPaidException.php
namespace App\Exceptions;

class OrderAlreadyPaidException extends ApiException
{
    public function __construct(public readonly int $orderId)
    {
        parent::__construct("Order {$orderId} is already paid");
    }

    public function statusCode(): int
    {
        return 409;
    }

    public function errorCode(): string
    {
        return 'ORDER_ALREADY_PAID';
    }

    public function context(): array
    {
        return ['order_id' => $this->orderId];
    }
}

Тепер всі помилки API мають однаковий формат:

{
  "error": {
    "code": "ORDER_ALREADY_PAID",
    "message": "Order 42 is already paid",
    "context": {
      "order_id": 42
    }
  }
}

Фронтенд парсить error.code для логіки і error.message для відображення користувачу. Один обробник на всі помилки.

Глобальний fallback для непередбачених помилок

Окрім бізнес-винятків, потрібен загальний обробник для всього, що ти не передбачив — недоступна БД, помилки у зовнішніх сервісах, баги в коді. У продакшні не можна показувати дефолтну Laravel-сторінку з debug-інфою або голий 500.

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (\Throwable $e, $request) {
        if (! $request->expectsJson()) {
            return null; // нехай Laravel рендерить HTML як зазвичай
        }

        // Стандартні випадки обробляються окремо
        if ($e instanceof ValidationException
            || $e instanceof AuthenticationException
            || $e instanceof ModelNotFoundException) {
            return null;
        }

        // Renderable exceptions самі себе зрендерять
        if (method_exists($e, 'render')) {
            return null;
        }

        $status = method_exists($e, 'getStatusCode') 
            ? $e->getStatusCode() 
            : 500;

        return response()->json([
            'error' => [
                'code' => 'INTERNAL_ERROR',
                'message' => app()->environment('production')
                    ? 'Something went wrong'
                    : $e->getMessage(),
            ],
        ], $status);
    });
})

Цей fallback гарантує, що навіть якщо щось зламається непередбачено — клієнт отримає JSON у тому ж форматі, що й інші помилки.

Логування: метод report() для контролю

Laravel автоматично логує всі винятки в storage/logs/laravel.log (плюс інтеграції типу Sentry). Але часто треба тонше керувати:

  • Бізнес-винятки (типу InsufficientBalance) логувати не треба — це не баги
  • 404 на API не варто спамити в Sentry
  • Критичні винятки треба ескалювати в Slack

Для цього є метод report() на самому винятку (повертає false — не логувати) або глобальна конфігурація:

// bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    // Не логувати взагалі
    $exceptions->dontReport([
        ModelNotFoundException::class,
        AuthenticationException::class,
        ValidationException::class,
    ]);

    // Або кастомна логіка логування
    $exceptions->report(function (\Throwable $e) {
        if (app()->environment('production')) {
            // Критичні помилки → Slack
            if ($e instanceof CriticalSystemException) {
                Notification::route('slack', config('alerts.slack_url'))
                    ->notify(new CriticalErrorNotification($e));
            }
        }
    });
})

Коли try-catch у контролері виправданий

Не все треба викидати назовні. Є кейси, де try-catch — це не обробка помилок, а бізнес-логіка:

1. Інтеграції з зовнішніми API з fallback

public function processPayment(Order $order)
{
    try {
        return $this->primaryGateway->charge($order);
    } catch (PaymentGatewayException $e) {
        Log::warning('Primary gateway failed, trying fallback', [
            'order_id' => $order->id,
            'error' => $e->getMessage(),
        ]);
        return $this->fallbackGateway->charge($order);
    }
}

2. Транзакції з компенсацією

public function createOrderWithExternalShipping(array $data)
{
    $order = Order::create($data);

    try {
        $shipmentId = $this->shippingApi->createShipment($order);
        $order->update(['shipment_id' => $shipmentId]);
    } catch (ShippingApiException $e) {
        // Зовнішній API не в нашій DB-транзакції,
        // тому компенсуємо вручну
        $order->delete();
        throw $e;
    }

    return $order;
}

3. Graceful degradation

public function getDashboardStats(): array
{
    try {
        return Cache::remember('dashboard_stats', 300, fn() => 
            $this->computeExpensiveStats()
        );
    } catch (RedisException $e) {
        // Redis недоступний — рахуємо без кешу
        Log::warning('Cache unavailable, computing without cache');
        return $this->computeExpensiveStats();
    }
}

4. Логування з контекстом + re-throw

public function importUsers(string $filePath)
{
    try {
        $this->importer->import($filePath);
    } catch (\Throwable $e) {
        Log::error('User import failed', [
            'file' => $filePath,
            'line' => $this->importer->currentLine(),
            'error' => $e->getMessage(),
        ]);
        throw $e; // кидаємо далі — нехай глобальний обробник дає 500
    }
}

У всіх цих випадках try-catch робить щось осмислене: fallback, компенсацію, додає контекст. На відміну від «обгорнули і повернули 500».

План рефакторингу для існуючого проєкту

Якщо у проєкті розкидано try-catch у кожному контролері — ось практичний порядок дій:

  1. Прибери всі try-catch, які повертають дефолтний 500. Це чистий шум, який ще й маскує реальні баги.
  2. Прибери ловлю стандартних винятків. ModelNotFoundException, ValidationException, AuthenticationException — Laravel їх обробить сам.
  3. Створи власні бізнес-винятки з методом render(). Один клас на один тип помилки.
  4. Уніфікуй формат через базовий ApiException. Усі винятки API повинні віддавати один формат.
  5. Додай глобальний fallback у bootstrap/app.php для всього непередбаченого.
  6. Налаштуй report(). Бізнес-винятки не логуй, критичні — ескалюй у Slack/Sentry.
  7. Залиш try-catch тільки там, де є осмислена бізнес-логіка — fallback, компенсація, graceful degradation.

Що отримуємо в результаті

Контролер до:

public function withdraw(Request $request)
{
    try {
        $user = $request->user();
        $amount = $request->validate(['amount' => 'required|numeric|min:0'])['amount'];
        
        if ($amount > $user->balance) {
            return response()->json([
                'message' => 'Insufficient balance',
                'required' => $amount,
                'available' => $user->balance,
            ], 422);
        }

        DB::transaction(function () use ($user, $amount) {
            $user->decrement('balance', $amount);
            Transaction::create([/* ... */]);
        });

        return response()->json(['status' => 'ok']);
    } catch (\Exception $e) {
        Log::error($e->getMessage());
        return response()->json(['message' => 'Something went wrong'], 500);
    }
}

Контролер після:

public function withdraw(WithdrawRequest $request, WithdrawAction $action)
{
    $action->execute($request->user(), $request->amount);
    
    return response()->json(['status' => 'ok']);
}

Уся валідація — у WithdrawRequest. Уся бізнес-логіка — у WithdrawAction. Усі помилки — кидаються винятками і обробляються централізовано. Контролер робить одне: маршрутизує запит у дію і повертає результат.

Практика

На моем сайте есть страница продукта, за нее отвечает этот контроллер:

class CatalogController extends Controller {

    public function product(string $slug)
    {
        $product = Product::where('slug', $slug)
            ->where('is_active', true)
            ->with(['category', 'brand', 'images', 'attributeValues.attribute'])
            ->firstOrFail();

        $breadcrumbs = $product->category->ancestorsAndSelf()->get()->reverse();

        $reviews = $product->reviews()
            ->where('is_approved', true)
            ->with('user')
            ->latest()
            ->get();

        $userReview = auth()->check()
            ? $product->reviews()->where('user_id', auth()->id())->first()
            : null;

        return view('catalog.product', compact('product', 'breadcrumbs', 'reviews', 'userReview'));
    }

}

Если открыть страницу несуществующего продукта, то увидим страницу 404.

У нас используется firstOrFail() — если продукта с таким слагом нет (или он неактивен), Laravel автоматически бросит исключение ModelNotFoundException, которое по
умолчанию отдаёт 404 страницу.

Як віддавати свій шаблон помилки?

Сейчас мы видим дефолтный шаблон Laravel.

Дефолтні шаблони лежать тут:

vendor/laravel/framework/src/Illuminate/Foundation/Exceptions/views/

├── layout.blade.php   ← базовий layout для всіх
  ├── minimal.blade.php  ← мінімальний варіант
  ├── 401.blade.php
  ├── 402.blade.php
  ├── 403.blade.php
  ├── 404.blade.php
  ├── 419.blade.php      ← CSRF token expired
  ├── 429.blade.php      ← Too Many Requests
  ├── 500.blade.php
  └── 503.blade.php

Створив файл resources/views/errors/404.blade.php — Laravel автоматично підхопить його для всіх 404 помилок.

Тепер. бачимо наш шаблон:

Laravel шукає шаблони помилок у resources/views/errors/ за HTTP-кодом. Ось ієрархія:

errors/
  ├── 404.blade.php   → тільки 404
  ├── 403.blade.php   → тільки 403
  ├── 4xx.blade.php   → 400, 401, 405, 422... (fallback)
  ├── 500.blade.php   → тільки 500
  └── 5xx.blade.php   → 500, 502, 503... (fallback)

А що якщо я хочу віддавати не шаблон?

Найпростіший варіант — замінити firstOrFail() на first() і перевірити вручну:

    public function product(string $slug)
    {
        $product = Product::where('slug', $slug)
            ->where('is_active', true)
            ->with(['category', 'brand', 'images', 'attributeValues.attribute'])
            ->first();
//            ->firstOrFail();

Тобто firstOrFail() — если продукта с таким слагом нет (или он неактивен), Laravel автоматически бросит исключение ModelNotFoundException, которое по умолчанию отдаёт 404 страницу. А first() – не кидає виняток, повертає null. Це означає, що далі в коді ти отримаєш помилку:

return $product->category->name; // ErrorException: Attempt to read property "category" on null

Це стандартна Laravel-сторінка помилки — вбудована у Laravel exception view (Illuminate\Foundation\Exceptions\Renderer). Активується коли APP_DEBUG=true і запит хоче HTML.

Поставимо APP_DEBUG=false.

Тому з first() треба завжди перевіряти результат:

public function show(string $slug)
{
    $product = Product::where('slug', $slug)
        ->where('is_active', true)
        ->first();
    
    if (! $product) {
        abort(404);
    }
    
    return view('product.show', compact('product'));
}

Що робить abort(404): під капотом кидає Symfony\Component\HttpKernel\Exception\NotFoundHttpException. Laravel ловить його і віддає 404-сторінку. Тобто це теж виняток, просто загорнутий у хелпер.

Є ще другий варіант – кинути самому Exception, наприклад ModelNotFoundException.

use Illuminate\Database\Eloquent\ModelNotFoundException;

public function show(string $slug)
{
    $product = Product::where('slug', $slug)
        ->where('is_active', true)
        ->first();
    
    if (! $product) {
        throw (new ModelNotFoundException)->setModel(Product::class, [$slug]);
    }
    
    return view('product.show', compact('product'));
}

Це той самий виняток, який кидає firstOrFail(). Laravel його теж обробить як 404.

Кастомний Exception

Третій варіант – власний Exception.

Якщо у проєкті треба розрізняти “продукта взагалі немає” і “продукт є, але неактивний” — це вже два різних бізнес-кейси, які можуть потребувати різних відповідей:

php artisan make:exception ProductNotAvailableException
<?php

namespace App\Exceptions;

use Exception;
use Illuminate\Http\Request;

class ProductNotAvailableException extends Exception
{
    public function __construct(public readonly string $slug)
    {
        parent::__construct("Product '{$slug}' is not available");
    }

    public function render(Request $request)
    {
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'product_unavailable',
                'message' => 'Цей продукт тимчасово недоступний',
                'slug' => $this->slug,
            ], 410); // 410 Gone — більш точно, ніж 404
        }

        return response()->view('errors.product-unavailable', [
            'slug' => $this->slug,
        ], 410);
    }
}

Тоді у контролері:

$product = Product::where('slug', $slug)
            ->where('is_active', true)
            ->with(['category', 'brand', 'images', 'attributeValues.attribute'])
            ->first();

        if (! $product) {
            throw new ProductNotAvailableException($slug);
        }

Так як запит очікує шаблон, то бачимо наш кастомний шаблон:

resources/views/errors/product-unavailable.blade.php

withExceptions() — глобальна логіка

// bootstrap/app.php

return Application::configure(basePath: dirname(__DIR__))
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->render(function (ModelNotFoundException $e, $request) {
            return response()->json(['message' => 'Not found lol'], 404);
        });

Тут ти кажеш Laravel: «коли впіймаєш будь-який ModelNotFoundException (а він кидається скрізь, де є findOrFail), оброби його ось так».

->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->render(function (ModelNotFoundException $e, Request $request) {
            if ($request->expectsJson()) {
                return response()->json([
                    'error' => 'model_not_found',
                    'model' => class_basename($e->getModel()),
                    'message' => 'Resource not found',
                ], 404);
            }
            return null; // HTML — стандартна 404 view
        });

Це потрібно для випадків, коли:

  • Виняток не твій — ти не можеш додати в нього render()
  • Один обробник для багатьох винятків — не дублювати код
  • Глобальні правила — логування, фільтрація, маскування

Ignition

Є ще Ignition — це бібліотека для відображення помилок.

composer require spatie/laravel-ignition

Ось так вона виглядає:

Підсумок

  • Laravel сам обробляє більшість стандартних винятків — не дублюй це у try-catch
  • У Laravel 11+ конфігурація винятків живе в bootstrap/app.php, а не в app/Exceptions/Handler.php
  • Для бізнес-помилок використовуй Renderable Exceptions — кожен виняток сам знає, як себе перетворити на відповідь
  • Уніфікуй формат через базовий ApiException, щоб фронтенд мав один обробник
  • Метод report() дає тонкий контроль над логуванням — бізнес-винятки не повинні спамити в Sentry
  • try-catch у контролері виправданий тільки для бізнес-логіки: fallback, компенсація, graceful degradation
  • Глобальний fallback у bootstrap/app.php ловить усе непередбачене і повертає у тому ж форматі

Чистий контролер на 3-5 рядків — це не магія і не спрощення картинки. Це наслідок того, що відповідальності розкладені по правильних місцях: валідація — у Form Request, логіка — у сервісах/Actions, помилки — у власних винятках з методом render().