REST API Development

API Error Handling & Exceptions

20 min Lesson 14 of 35

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:
  1. Create a comprehensive exception handler that returns consistent JSON error responses for all exception types (validation, authentication, authorization, not found, server errors)
  2. Build custom exception classes for domain-specific errors (PaymentFailedException, SubscriptionExpiredException, etc.)
  3. Implement error logging with contextual information (user ID, IP, request data) and integrate with Sentry or Bugsnag
  4. Create a trait or helper class that provides standardized error response methods for controllers
  5. 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.