HardТеория7 min

Обработка ошибок

Exception Handler, reportable и renderable исключения, HTTP-исключения, abort(), кастомные страницы ошибок, контекст в Laravel 11

Обработка ошибок в Laravel

Laravel 11 упростил обработку ошибок, переместив всю настройку из класса App\Exceptions\Handler в файл bootstrap/app.php. Это часть философии "минималистичной структуры" Laravel 11.

Конфигурация в bootstrap/app.php

// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
        api: __DIR__ . '/../routes/api.php',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // Middleware configuration
    })
    ->withExceptions(function (Exceptions $exceptions) {
        // Exception handling configuration goes here
    })
    ->create();

Reporting исключений

Reporting отвечает за логирование исключений и отправку во внешние сервисы (Sentry, Bugsnag и т.д.):

Регистрация reportable callback

->withExceptions(function (Exceptions $exceptions) {
    // Report to external service
    $exceptions->reportable(function (\App\Exceptions\PaymentException $e) {
        // This runs IN ADDITION to default logging
        app('sentry')->captureException($e);
    });

    // Stop default logging after custom report
    $exceptions->reportable(function (\App\Exceptions\ExternalApiException $e) {
        Log::channel('api-errors')->error($e->getMessage(), [
            'url' => $e->getUrl(),
            'status' => $e->getStatusCode(),
        ]);
    })->stop(); // Prevent default logging

    // Don't report specific exceptions
    $exceptions->dontReport([
        \App\Exceptions\BusinessLogicException::class,
        \App\Exceptions\UserVisibleException::class,
    ]);

    // Don't report based on condition
    $exceptions->stopIgnoring(\Illuminate\Auth\AuthenticationException::class);
})

Глобальный контекст

Добавление контекстных данных ко всем логируемым исключениям:

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->context(fn () => [
        'user_id' => auth()->id(),
        'url' => request()->fullUrl(),
        'ip' => request()->ip(),
        'user_agent' => request()->userAgent(),
    ]);
})

Report helper

// Report exception without stopping execution
report($exception);

// Report using helper function
report(new RuntimeException('Something unexpected happened.'));

// Conditionally report
if ($order->total > 10000) {
    report(new HighValueOrderException($order));
}

Rendering исключений

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

Регистрация renderable callback

->withExceptions(function (Exceptions $exceptions) {
    // Render custom response for specific exception
    $exceptions->renderable(function (\App\Exceptions\InsufficientFundsException $e, $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'insufficient_funds',
                'message' => $e->getMessage(),
                'required' => $e->getRequiredAmount(),
                'available' => $e->getAvailableAmount(),
            ], 402);
        }

        return response()->view('errors.insufficient-funds', [
            'exception' => $e,
        ], 402);
    });

    // Handle API validation differently
    $exceptions->renderable(function (\Illuminate\Validation\ValidationException $e, $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'message' => 'Validation failed.',
                'errors' => $e->errors(),
                'code' => 'VALIDATION_ERROR',
            ], 422);
        }
    });

    // Handle 404 for API
    $exceptions->renderable(function (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e, $request) {
        if ($request->is('api/*')) {
            return response()->json([
                'message' => 'Resource not found.',
                'code' => 'NOT_FOUND',
            ], 404);
        }
    });
})

Reportable и Renderable исключения

Исключения могут сами определять, как их логировать и отображать:

declare(strict_types=1);

namespace App\Exceptions;

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

final class OrderProcessingException extends Exception
{
    public function __construct(
        string $message,
        private readonly string $orderId,
        private readonly string $errorCode,
        int $code = 0,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, $code, $previous);
    }

    /**
     * Report the exception.
     * Return false to prevent default logging.
     */
    public function report(): bool
    {
        Log::channel('orders')->error('Order processing failed', [
            'order_id' => $this->orderId,
            'error_code' => $this->errorCode,
            'message' => $this->getMessage(),
        ]);

        // Return false to PREVENT default logging
        // Return true or void to ALLOW default logging too
        return false;
    }

    /**
     * Render the exception into an HTTP response.
     */
    public function render(Request $request): JsonResponse|Response
    {
        if ($request->expectsJson()) {
            return response()->json([
                'error' => 'order_processing_failed',
                'message' => $this->getMessage(),
                'order_id' => $this->orderId,
                'code' => $this->errorCode,
            ], 500);
        }

        return response()->view('errors.order-failed', [
            'orderId' => $this->orderId,
            'message' => $this->getMessage(),
        ], 500);
    }

    /**
     * Get additional context for logging.
     */
    public function context(): array
    {
        return [
            'order_id' => $this->orderId,
            'error_code' => $this->errorCode,
        ];
    }
}

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

throw new OrderProcessingException(
    message: 'Payment gateway timeout.',
    orderId: $order->id,
    errorCode: 'GATEWAY_TIMEOUT',
);

::alert{type="warning"} Для экзамена: Если метод report() в исключении возвращает false, стандартное логирование предотвращается. Если возвращает true, void или ничего не возвращает - стандартное логирование выполняется в дополнение к кастомному. ::

HTTP-исключения и abort()

Функция abort()

// Abort with status code
abort(404);
abort(403, 'Access denied.');
abort(500, 'Internal server error.');

// Abort with custom headers
abort(503, 'Service unavailable.', ['Retry-After' => '3600']);

abort_if() и abort_unless()

// Abort if condition is true
abort_if(!$user->isAdmin(), 403, 'Admin access required.');
abort_if($article->is_draft && !auth()->check(), 404);

// Abort unless condition is true
abort_unless(auth()->check(), 401);
abort_unless($user->can('edit', $article), 403);

HTTP-исключения

use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;

// These are equivalent:
abort(404);
throw new NotFoundHttpException('Page not found.');

abort(403);
throw new AccessDeniedHttpException('Access denied.');

// Custom HTTP exception
throw new HttpException(
    statusCode: 429,
    message: 'Too many requests.',
    headers: ['Retry-After' => '60']
);

Кастомные страницы ошибок

Публикация стандартных страниц

php artisan vendor:publish --tag=laravel-errors
# Publishes to resources/views/errors/

Создание кастомных страниц

{{-- resources/views/errors/404.blade.php --}}
<x-layouts.app>
    <x-slot:title>Page Not Found</x-slot:title>

    <div class="flex items-center justify-center min-h-[60vh]">
        <div class="text-center">
            <h1 class="text-9xl font-bold text-gray-200">404</h1>
            <h2 class="text-2xl font-semibold text-gray-700 mt-4">
                Page Not Found
            </h2>
            <p class="text-gray-500 mt-2">
                The page you're looking for doesn't exist or has been moved.
            </p>
            <a href="{{ route('home') }}"
               class="inline-block mt-6 px-6 py-3 bg-blue-600 text-white rounded-lg">
                Go Home
            </a>
        </div>
    </div>
</x-layouts.app>
{{-- resources/views/errors/500.blade.php --}}
<x-layouts.error>
    <div class="text-center">
        <h1 class="text-6xl font-bold text-red-500">500</h1>
        <p class="text-xl mt-4">Something went wrong on our end.</p>
        <p class="text-gray-500 mt-2">Our team has been notified.</p>
    </div>
</x-layouts.error>

{{-- resources/views/errors/403.blade.php --}}
<x-layouts.app>
    <div class="text-center py-20">
        <h1 class="text-6xl font-bold text-yellow-500">403</h1>
        <p class="text-xl mt-4">{{ $exception->getMessage() ?: 'Access Forbidden' }}</p>
    </div>
</x-layouts.app>

{{-- resources/views/errors/503.blade.php --}}
<!DOCTYPE html>
<html>
<head>
    <title>Maintenance Mode</title>
    <style>
        body { font-family: sans-serif; text-align: center; padding: 100px; }
    </style>
</head>
<body>
    <h1>We'll be right back!</h1>
    <p>We're performing maintenance. Please check back soon.</p>
    @if ($exception->retryAfter)
        <p>Estimated downtime: {{ $exception->retryAfter }} seconds</p>
    @endif
</body>
</html>

Fallback error page

{{-- resources/views/errors/minimal.blade.php --}}
{{-- Used for any HTTP error code without a specific template --}}
<!DOCTYPE html>
<html>
<head>
    <title>Error {{ $status }}</title>
</head>
<body>
    <h1>Error {{ $status }}</h1>
    <p>{{ $message }}</p>
</body>
</html>

Контекст исключений

Добавление контекста к исключению

declare(strict_types=1);

namespace App\Exceptions;

use Exception;

final class ImportException extends Exception
{
    public function __construct(
        string $message,
        private readonly string $fileName,
        private readonly int $lineNumber,
        private readonly array $rawData,
    ) {
        parent::__construct($message);
    }

    /**
     * Get the exception's context information.
     * This data is automatically added to log entries.
     *
     * @return array<string, mixed>
     */
    public function context(): array
    {
        return [
            'file_name' => $this->fileName,
            'line_number' => $this->lineNumber,
            'raw_data' => $this->rawData,
        ];
    }
}

Глобальный контекст через middleware

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Log\Context\Repository;

final class AddRequestContext
{
    public function handle(Request $request, Closure $next): mixed
    {
        // Add context that will be included in all log entries
        $context = app(Repository::class);
        $context->add('request_id', (string) Str::uuid());
        $context->add('user_id', auth()->id());
        $context->add('ip', $request->ip());

        return $next($request);
    }
}

Не игнорируемые исключения

->withExceptions(function (Exceptions $exceptions) {
    // These exception types will never be reported
    $exceptions->dontReport([
        \App\Exceptions\InvalidArgumentException::class,
    ]);

    // Throttle exception reporting (avoid log flooding)
    $exceptions->throttle(function (\Throwable $e) {
        if ($e instanceof \App\Exceptions\ApiRateLimitException) {
            return Limit::perMinute(5);
        }

        // No throttling for other exceptions
        return Limit::none();
    });
})

Обработка ошибок в production vs development

// config/app.php (or .env)
// APP_DEBUG=true  -> Detailed error pages (Ignition)
// APP_DEBUG=false -> Generic error pages (resources/views/errors/)

::alert{type="danger"} Безопасность: НИКОГДА не устанавливайте APP_DEBUG=true в production! Детальные страницы ошибок раскрывают чувствительную информацию: пути к файлам, переменные окружения, SQL-запросы, стек вызовов. ::

Тестирование обработки ошибок

declare(strict_types=1);

namespace Tests\Feature;

use App\Exceptions\OrderProcessingException;
use Illuminate\Support\Facades\Log;
use Tests\TestCase;

final class ErrorHandlingTest extends TestCase
{
    public function test_404_returns_custom_page(): void
    {
        $response = $this->get('/non-existent-page');

        $response->assertStatus(404);
        $response->assertSee('Page Not Found');
    }

    public function test_403_for_unauthorized_user(): void
    {
        $user = User::factory()->create();

        $response = $this->actingAs($user)
            ->get('/admin/dashboard');

        $response->assertStatus(403);
    }

    public function test_api_404_returns_json(): void
    {
        $response = $this->getJson('/api/v1/users/99999');

        $response->assertStatus(404);
        $response->assertJson([
            'message' => 'Resource not found.',
        ]);
    }

    public function test_exception_is_reported(): void
    {
        Log::shouldReceive('channel->error')
            ->once()
            ->withArgs(fn ($message) => str_contains($message, 'Order processing failed'));

        $this->expectException(OrderProcessingException::class);

        throw new OrderProcessingException(
            message: 'Test error',
            orderId: 'ORD-001',
            errorCode: 'TEST',
        );
    }

    public function test_exception_without_reporting(): void
    {
        $this->withoutExceptionHandling();

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Test message');

        $this->get('/route-that-throws');
    }
}

Проверь себя

Что делает метод context() в классе исключения?

Что произойдёт, если метод report() в исключении вернёт false?

Почему опасно устанавливать APP_DEBUG=true в production?

Чем отличается abort_if() от abort_unless()?

Куда переместилась настройка обработки ошибок в Laravel 11?