Обработка ошибок в 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');
}
}