Introduction to API Error Handling
Proper error handling is one of the most critical yet often overlooked aspects of API development. Well-designed error responses help developers debug issues quickly, improve user experience, and maintain API security. In this comprehensive lesson, we'll explore Laravel's exception handling system and learn how to create consistent, informative error responses for your REST APIs.
Why Proper Error Handling Matters
Effective error handling provides multiple benefits for both API consumers and maintainers:
Benefits of Proper Error Handling:
- Developer Experience: Clear error messages help API consumers understand and fix issues quickly
- Security: Prevents sensitive information leakage through error messages
- Debugging: Structured errors make troubleshooting easier
- User Experience: Enables frontend applications to display meaningful error messages
- API Consistency: Standardized error format across all endpoints
- Monitoring: Makes it easier to track and analyze API failures
Standard HTTP Status Codes
Understanding HTTP status codes is fundamental to proper error handling:
// 2xx Success
200 OK - Request succeeded
201 Created - Resource created successfully
204 No Content - Success but no content to return
// 4xx Client Errors
400 Bad Request - Invalid request syntax or validation failure
401 Unauthorized - Authentication required or failed
403 Forbidden - Authenticated but not authorized
404 Not Found - Resource doesn't exist
405 Method Not Allowed - HTTP method not supported
409 Conflict - Request conflicts with current state
422 Unprocessable Entity - Validation errors
429 Too Many Requests - Rate limit exceeded
// 5xx Server Errors
500 Internal Server Error - Generic server error
502 Bad Gateway - Invalid response from upstream server
503 Service Unavailable - Server temporarily unavailable
504 Gateway Timeout - Upstream server timeout
Laravel's Exception Handler
Laravel's exception handler is located in app/Exceptions/Handler.php. This is where you customize error responses:
<?php
// app/Exceptions/Handler.php
namespace App\Exceptions;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
class Handler extends ExceptionHandler
{
protected $dontReport = [
// Exceptions that shouldn't be reported to logs
];
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
public function register(): void
{
$this->reportable(function (Throwable $e) {
// Custom error reporting logic
});
}
public function render($request, Throwable $exception)
{
// Return JSON for API requests
if ($request->expectsJson()) {
return $this->handleApiException($request, $exception);
}
return parent::render($request, $exception);
}
protected function handleApiException($request, Throwable $exception): JsonResponse
{
// Handle specific exception types
if ($exception instanceof ModelNotFoundException) {
return response()->json([
'error' => 'Resource not found',
'message' => 'The requested resource does not exist.',
], 404);
}
if ($exception instanceof AuthenticationException) {
return response()->json([
'error' => 'Unauthenticated',
'message' => 'You must be authenticated to access this resource.',
], 401);
}
if ($exception instanceof ValidationException) {
return response()->json([
'error' => 'Validation failed',
'message' => 'The given data was invalid.',
'errors' => $exception->errors(),
], 422);
}
if ($exception instanceof NotFoundHttpException) {
return response()->json([
'error' => 'Endpoint not found',
'message' => 'The requested endpoint does not exist.',
], 404);
}
// Default error response
$statusCode = method_exists($exception, 'getStatusCode')
? $exception->getStatusCode()
: 500;
$message = config('app.debug')
? $exception->getMessage()
: 'An error occurred while processing your request.';
return response()->json([
'error' => 'Server Error',
'message' => $message,
], $statusCode);
}
}
</pre>
Pro Tip: Use $request->expectsJson() to detect API requests and return JSON responses instead of HTML error pages.
Standardized Error Response Format
Create a consistent error response structure across your API:
<?php
// app/Traits/ApiResponses.php
namespace App\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponses
{
protected function successResponse($data, string $message = null, int $code = 200): JsonResponse
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $data,
], $code);
}
protected function errorResponse(string $message, int $code = 400, array $errors = []): JsonResponse
{
$response = [
'success' => false,
'message' => $message,
];
if (!empty($errors)) {
$response['errors'] => $errors;
}
if (config('app.debug')) {
$response['debug'] => [
'file' => debug_backtrace()[0]['file'] ?? null,
'line' => debug_backtrace()[0]['line'] ?? null,
];
}
return response()->json($response, $code);
}
protected function notFoundResponse(string $message = 'Resource not found'): JsonResponse
{
return $this->errorResponse($message, 404);
}
protected function unauthorizedResponse(string $message = 'Unauthorized'): JsonResponse
{
return $this->errorResponse($message, 401);
}
protected function forbiddenResponse(string $message = 'Forbidden'): JsonResponse
{
return $this->errorResponse($message, 403);
}
protected function validationErrorResponse(array $errors): JsonResponse
{
return response()->json([
'success' => false,
'message' => 'The given data was invalid.',
'errors' => $errors,
], 422);
}
protected function serverErrorResponse(string $message = 'Server error'): JsonResponse
{
return $this->errorResponse($message, 500);
}
}
// Usage in controllers
use App\Traits\ApiResponses;
class PostController extends Controller
{
use ApiResponses;
public function show($id)
{
$post = Post::find($id);
if (!$post) {
return $this->notFoundResponse('Post not found');
}
return $this->successResponse($post, 'Post retrieved successfully');
}
}
</pre>
Custom API Exceptions
Create custom exception classes for specific error scenarios:
<?php
// app/Exceptions/ResourceNotFoundException.php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
class ResourceNotFoundException extends Exception
{
protected $message;
protected $resourceType;
public function __construct(string $resourceType = 'Resource', string $message = null)
{
$this->resourceType = $resourceType;
$this->message = $message ?? "{$resourceType} not found";
parent::__construct($this->message);
}
public function render($request): JsonResponse
{
return response()->json([
'error' => 'ResourceNotFound',
'message' => $this->message,
'resource_type' => $this->resourceType,
], 404);
}
public function report(): bool
{
// Don't report 404s to error tracking
return false;
}
}
// app/Exceptions/UnauthorizedException.php
class UnauthorizedException extends Exception
{
public function render($request): JsonResponse
{
return response()->json([
'error' => 'Unauthorized',
'message' => $this->getMessage() ?: 'You are not authorized to perform this action.',
], 403);
}
}
// app/Exceptions/InvalidRequestException.php
class InvalidRequestException extends Exception
{
protected $errors;
public function __construct(string $message, array $errors = [])
{
parent::__construct($message);
$this->errors = $errors;
}
public function render($request): JsonResponse
{
$response = [
'error' => 'InvalidRequest',
'message' => $this->getMessage(),
];
if (!empty($this->errors)) {
$response['errors'] => $this->errors;
}
return response()->json($response, 400);
}
}
// Usage in controllers
public function update(Request $request, $id)
{
$post = Post::find($id);
if (!$post) {
throw new ResourceNotFoundException('Post');
}
if ($post->author_id !== auth()->id()) {
throw new UnauthorizedException('You can only update your own posts.');
}
// Update logic...
}
</pre>
Validation Error Handling
Laravel's validation automatically returns 422 responses with error details:
<?php
// Automatic validation error response
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'status' => 'required|in:draft,published',
]);
// If validation fails, Laravel automatically returns:
// {
// "message": "The given data was invalid.",
// "errors": {
// "title": ["The title field is required."],
// "status": ["The status field is required."]
// }
// }
$post = Post::create($validated);
return response()->json($post, 201);
}
// Custom validation error messages
$request->validate([
'email' => 'required|email',
'password' => 'required|min:8',
], [
'email.required' => 'Please provide your email address.',
'email.email' => 'Please provide a valid email address.',
'password.required' => 'Password is required.',
'password.min' => 'Password must be at least 8 characters.',
]);
</pre>
Custom Validation Error Format
Override validation error format in the exception handler:
<?php
// app/Exceptions/Handler.php
protected function invalidJson($request, ValidationException $exception): JsonResponse
{
return response()->json([
'success' => false,
'error' => 'ValidationError',
'message' => $exception->getMessage(),
'errors' => $exception->errors(),
'status_code' => 422,
], 422);
}
</pre>
Database Query Exceptions
Handle database-related errors gracefully:
<?php
use Illuminate\Database\QueryException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
public function handleApiException($request, Throwable $exception): JsonResponse
{
// Model not found
if ($exception instanceof ModelNotFoundException) {
$model = class_basename($exception->getModel());
return response()->json([
'error' => 'ResourceNotFound',
'message' => "{$model} not found",
], 404);
}
// Database query errors
if ($exception instanceof QueryException) {
// Don't expose SQL details in production
if (config('app.debug')) {
return response()->json([
'error' => 'DatabaseError',
'message' => $exception->getMessage(),
'sql' => $exception->getSql(),
], 500);
}
return response()->json([
'error' => 'DatabaseError',
'message' => 'A database error occurred.',
], 500);
}
// ... other exception handlers
}
</pre>
Security Warning: Never expose database structure, SQL queries, or stack traces in production. Use config('app.debug') to conditionally include debugging information only in development environments.
Authorization Exceptions
Handle authorization failures from policies and gates:
<?php
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
public function handleApiException($request, Throwable $exception): JsonResponse
{
// User not authenticated
if ($exception instanceof AuthenticationException) {
return response()->json([
'error' => 'Unauthenticated',
'message' => 'Authentication is required to access this resource.',
], 401);
}
// User authenticated but not authorized
if ($exception instanceof AuthorizationException) {
return response()->json([
'error' => 'Forbidden',
'message' => $exception->getMessage() ?: 'You are not authorized to perform this action.',
], 403);
}
// ... other handlers
}
// Usage with policies
public function update(Request $request, Post $post)
{
// Automatically throws AuthorizationException if unauthorized
$this->authorize('update', $post);
$post->update($request->validated());
return response()->json($post);
}
</pre>
Rate Limiting Exceptions
Handle rate limit exceptions with retry information:
<?php
use Illuminate\Http\Exceptions\ThrottleRequestsException;
public function handleApiException($request, Throwable $exception): JsonResponse
{
if ($exception instanceof ThrottleRequestsException) {
$retryAfter = $exception->getHeaders()['Retry-After'] ?? 60;
return response()->json([
'error' => 'TooManyRequests',
'message' => 'Rate limit exceeded. Please try again later.',
'retry_after' => (int) $retryAfter,
], 429)->withHeaders([
'Retry-After' => $retryAfter,
'X-RateLimit-Reset' => time() + $retryAfter,
]);
}
// ... other handlers
}
</pre>
Error Logging and Monitoring
Implement comprehensive error logging for debugging and monitoring:
<?php
// app/Exceptions/Handler.php
use Illuminate\Support\Facades\Log;
public function report(Throwable $exception): void
{
// Don't report certain exceptions
if ($this->shouldntReport($exception)) {
return;
}
// Log with context
Log::error($exception->getMessage(), [
'exception' => get_class($exception),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
'url' => request()->fullUrl(),
'method' => request()->method(),
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
parent::report($exception);
}
// Custom reportable exceptions
$this->reportable(function (ResourceNotFoundException $e) {
// Custom logging for resource not found
Log::info('Resource not found', [
'resource' => $e->resourceType,
'url' => request()->fullUrl(),
]);
});
$this->reportable(function (QueryException $e) {
// Alert team about database errors
Log::critical('Database error occurred', [
'sql' => $e->getSql(),
'bindings' => $e->getBindings(),
]);
});
</pre>
Third-Party Error Tracking
Integrate with services like Sentry, Bugsnag, or Flare for advanced error tracking:
<?php
// Install Sentry
// composer require sentry/sentry-laravel
// config/sentry.php
return [
'dsn' => env('SENTRY_LARAVEL_DSN'),
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE', 0.2),
'environment' => env('APP_ENV', 'production'),
];
// Automatically captures exceptions
// Add user context
if (auth()->check()) {
\Sentry\configureScope(function (\Sentry\State\Scope $scope): void {
$scope->setUser([
'id' => auth()->id(),
'email' => auth()->user()->email,
]);
});
}
// Manual error capture
try {
// Risky operation
} catch (Exception $e) {
\Sentry\captureException($e);
throw $e;
}
</pre>
Testing Error Responses
Write tests to ensure error handling works correctly:
<?php
// tests/Feature/ApiErrorHandlingTest.php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ApiErrorHandlingTest extends TestCase
{
use RefreshDatabase;
public function test_returns_404_for_non_existent_resource()
{
$response = $this->getJson('/api/posts/99999');
$response->assertStatus(404)
->assertJson([
'error' => 'ResourceNotFound',
]);
}
public function test_returns_401_for_unauthenticated_requests()
{
$response = $this->postJson('/api/posts', [
'title' => 'Test Post',
]);
$response->assertStatus(401)
->assertJson([
'error' => 'Unauthenticated',
]);
}
public function test_returns_403_for_unauthorized_actions()
{
$user = User::factory()->create();
$post = Post::factory()->create();
$response = $this->actingAs($user)
->deleteJson("/api/posts/{$post->id}");
$response->assertStatus(403)
->assertJson([
'error' => 'Forbidden',
]);
}
public function test_returns_422_for_validation_errors()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/posts', [
'title' => '', // Invalid: required
]);
$response->assertStatus(422)
->assertJsonStructure([
'message',
'errors' => ['title'],
]);
}
public function test_returns_429_for_rate_limit_exceeded()
{
// Make requests until rate limited
for ($i = 0; $i < 61; $i++) {
$response = $this->getJson('/api/posts');
}
$response->assertStatus(429)
->assertJsonStructure([
'error',
'retry_after',
]);
}
}
</pre>
Practice Exercise:
- Create a comprehensive exception handler that returns consistent JSON error responses for all exception types (validation, authentication, authorization, not found, server errors)
- Build custom exception classes for domain-specific errors (PaymentFailedException, SubscriptionExpiredException, etc.)
- Implement error logging with contextual information (user ID, IP, request data) and integrate with Sentry or Bugsnag
- Create a trait or helper class that provides standardized error response methods for controllers
- Write comprehensive tests that cover all error scenarios (401, 403, 404, 422, 429, 500) and verify correct error response structure
Best Practices
Error Handling Best Practices:
- Be consistent: Use the same error response structure across all endpoints
- Be informative: Provide clear, actionable error messages
- Be secure: Never expose sensitive information in production errors
- Use proper status codes: Follow HTTP status code conventions
- Log everything: Comprehensive logging helps with debugging
- Include error codes: Use unique error codes for programmatic error handling
- Document errors: Document possible error responses in API documentation
- Test error scenarios: Write tests for all error cases
Summary
In this lesson, you've mastered API error handling in Laravel. You now understand how to customize Laravel's exception handler, create consistent error response formats, build custom exception classes for specific scenarios, handle validation and authorization errors, implement comprehensive error logging, integrate third-party error tracking services, and test error responses thoroughly. Proper error handling is essential for creating professional, maintainable APIs that provide excellent developer experience and help you diagnose issues quickly in production.