Error Handling Standards
Overview
This document defines the standards for handling errors and exceptions throughout the backend application. Consistent error handling improves debugging, user experience, and application reliability.
Error Response Format
Standard Error Structure
All API errors must follow this consistent JSON structure:
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error message",
"details": {}
}
}Response Components
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Machine-readable error code (UPPER_SNAKE_CASE) |
message | string | Yes | Human-readable error description |
details | object | No | Additional context (validation errors, etc.) |
Example Responses
Validation Error (422)
{
"error": {
"code": "VALIDATION_ERROR",
"message": "The given data was invalid.",
"details": {
"email": [
"The email field is required.",
"The email must be a valid email address."
],
"password": [
"The password must be at least 8 characters."
]
}
}
}Not Found Error (404)
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "The requested user was not found.",
"details": {
"resource": "User",
"id": 123
}
}
}Unauthorized Error (401)
{
"error": {
"code": "UNAUTHORIZED",
"message": "Authentication is required to access this resource.",
"details": {
"required": "Bearer token"
}
}
}Forbidden Error (403)
{
"error": {
"code": "FORBIDDEN",
"message": "You do not have permission to perform this action.",
"details": {
"required_permission": "users.delete",
"user_permissions": ["users.read", "users.update"]
}
}
}Server Error (500)
{
"error": {
"code": "INTERNAL_SERVER_ERROR",
"message": "An unexpected error occurred. Please try again later.",
"details": {
"request_id": "req_abc123"
}
}
}Error Codes
Standard Error Codes
| Code | HTTP Status | Description |
|---|---|---|
VALIDATION_ERROR | 422 | Request data validation failed |
RESOURCE_NOT_FOUND | 404 | Requested resource doesn't exist |
UNAUTHORIZED | 401 | Authentication required or failed |
FORBIDDEN | 403 | User lacks required permissions |
AUTHENTICATION_FAILED | 401 | Invalid credentials |
TOKEN_EXPIRED | 401 | Access token has expired |
TOKEN_INVALID | 401 | Token is malformed or invalid |
RATE_LIMIT_EXCEEDED | 429 | Too many requests |
DUPLICATE_RESOURCE | 409 | Resource already exists |
INVALID_REQUEST | 400 | Malformed request |
METHOD_NOT_ALLOWED | 405 | HTTP method not supported |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
SERVICE_UNAVAILABLE | 503 | Service temporarily unavailable |
DATABASE_ERROR | 500 | Database operation failed |
Domain-Specific Error Codes
Create specific error codes for domain operations:
USER_NOT_FOUND
USER_ALREADY_EXISTS
USER_ACCOUNT_DISABLED
USER_EMAIL_NOT_VERIFIED
POST_NOT_FOUND
POST_ALREADY_PUBLISHED
POST_CANNOT_DELETE_PUBLISHED
PAYMENT_FAILED
PAYMENT_CARD_DECLINED
PAYMENT_INSUFFICIENT_FUNDSException Hierarchy
Base Exception
Create a base exception class for the application:
// app/Exceptions/BaseException.php
namespace App\Exceptions;
use Exception;
abstract class BaseException extends Exception
{
protected string $errorCode;
protected int $statusCode;
protected array $details = [];
public function __construct(
string $message = '',
array $details = [],
?\Throwable $previous = null
) {
parent::__construct($message, 0, $previous);
$this->details = $details;
}
public function getErrorCode(): string
{
return $this->errorCode;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function getDetails(): array
{
return $this->details;
}
public function toArray(): array
{
return [
'error' => [
'code' => $this->errorCode,
'message' => $this->message,
'details' => $this->details,
]
];
}
}Common Exceptions
Resource Not Found
// app/Exceptions/ResourceNotFoundException.php
namespace App\Exceptions;
class ResourceNotFoundException extends BaseException
{
protected string $errorCode = 'RESOURCE_NOT_FOUND';
protected int $statusCode = 404;
public function __construct(
string $resource = 'Resource',
mixed $id = null,
?\Throwable $previous = null
) {
$message = "The requested {$resource} was not found.";
$details = $id ? ['resource' => $resource, 'id' => $id] : [];
parent::__construct($message, $details, $previous);
}
}Validation Exception
// app/Exceptions/ValidationException.php
namespace App\Exceptions;
class ValidationException extends BaseException
{
protected string $errorCode = 'VALIDATION_ERROR';
protected int $statusCode = 422;
public function __construct(
array $errors,
string $message = 'The given data was invalid.',
?\Throwable $previous = null
) {
parent::__construct($message, $errors, $previous);
}
}Authorization Exception
// app/Exceptions/AuthorizationException.php
namespace App\Exceptions;
class AuthorizationException extends BaseException
{
protected string $errorCode = 'FORBIDDEN';
protected int $statusCode = 403;
public function __construct(
string $message = 'You do not have permission to perform this action.',
array $details = [],
?\Throwable $previous = null
) {
parent::__construct($message, $details, $previous);
}
}Authentication Exception
// app/Exceptions/AuthenticationException.php
namespace App\Exceptions;
class AuthenticationException extends BaseException
{
protected string $errorCode = 'UNAUTHORIZED';
protected int $statusCode = 401;
public function __construct(
string $message = 'Authentication is required to access this resource.',
array $details = [],
?\Throwable $previous = null
) {
parent::__construct($message, $details, $previous);
}
}Domain-Specific Exceptions
// app/Exceptions/Domain/UserNotFoundException.php
namespace App\Exceptions\Domain;
use App\Exceptions\BaseException;
class UserNotFoundException extends BaseException
{
protected string $errorCode = 'USER_NOT_FOUND';
protected int $statusCode = 404;
public function __construct(
int $userId,
?\Throwable $previous = null
) {
parent::__construct(
"User with ID {$userId} was not found.",
['user_id' => $userId],
$previous
);
}
}Exception Handler
Global Exception Handler
Configure the global exception handler:
// app/Exceptions/Handler.php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Auth\Access\AuthorizationException as LaravelAuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Throwable;
class Handler extends ExceptionHandler
{
protected $dontReport = [
ValidationException::class,
AuthenticationException::class,
];
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
public function register(): void
{
$this->reportable(function (Throwable $e) {
// Custom logging logic
});
$this->renderable(function (Throwable $e, $request) {
if ($request->expectsJson()) {
return $this->handleApiException($e, $request);
}
});
}
protected function handleApiException(Throwable $e, $request)
{
// Handle custom exceptions
if ($e instanceof BaseException) {
return response()->json(
$e->toArray(),
$e->getStatusCode()
);
}
// Handle Laravel validation exceptions
if ($e instanceof ValidationException) {
return response()->json([
'error' => [
'code' => 'VALIDATION_ERROR',
'message' => 'The given data was invalid.',
'details' => $e->errors(),
]
], 422);
}
// Handle model not found
if ($e instanceof ModelNotFoundException) {
$model = class_basename($e->getModel());
return response()->json([
'error' => [
'code' => 'RESOURCE_NOT_FOUND',
'message' => "The requested {$model} was not found.",
'details' => ['resource' => $model]
]
], 404);
}
// Handle 404 errors
if ($e instanceof NotFoundHttpException) {
return response()->json([
'error' => [
'code' => 'NOT_FOUND',
'message' => 'The requested endpoint was not found.',
]
], 404);
}
// Handle method not allowed
if ($e instanceof MethodNotAllowedHttpException) {
return response()->json([
'error' => [
'code' => 'METHOD_NOT_ALLOWED',
'message' => 'The HTTP method is not supported for this endpoint.',
]
], 405);
}
// Handle authentication exceptions
if ($e instanceof AuthenticationException) {
return response()->json([
'error' => [
'code' => 'UNAUTHORIZED',
'message' => 'Authentication is required.',
]
], 401);
}
// Handle authorization exceptions
if ($e instanceof LaravelAuthorizationException) {
return response()->json([
'error' => [
'code' => 'FORBIDDEN',
'message' => $e->getMessage() ?: 'You do not have permission to perform this action.',
]
], 403);
}
// Handle all other exceptions
$statusCode = method_exists($e, 'getStatusCode')
? $e->getStatusCode()
: 500;
$response = [
'error' => [
'code' => 'INTERNAL_SERVER_ERROR',
'message' => 'An unexpected error occurred. Please try again later.',
]
];
// Include error details in development
if (config('app.debug')) {
$response['error']['details'] = [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
];
}
return response()->json($response, $statusCode);
}
}Using Exceptions
In Controllers
// app/Http/Controllers/Api/V1/UserController.php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Exceptions\Domain\UserNotFoundException;
use App\Models\User;
class UserController extends Controller
{
public function show(int $id)
{
$user = User::find($id);
if (!$user) {
throw new UserNotFoundException($id);
}
return response()->json(['data' => $user]);
}
public function destroy(int $id)
{
$user = User::find($id);
if (!$user) {
throw new UserNotFoundException($id);
}
// Check authorization
$this->authorize('delete', $user);
$user->delete();
return response()->json(null, 204);
}
}In Services
// app/Services/User/UserService.php
namespace App\Services\User;
use App\Models\User;
use App\Exceptions\Domain\UserNotFoundException;
use App\Exceptions\ValidationException;
class UserService
{
public function updateUser(int $userId, array $data): User
{
$user = User::find($userId);
if (!$user) {
throw new UserNotFoundException($userId);
}
// Business logic validation
if ($user->isAdmin() && !$data['is_admin']) {
throw new ValidationException(
['role' => ['Cannot remove admin role from the last admin user.']],
'Cannot modify admin role.'
);
}
$user->update($data);
return $user;
}
}Try-Catch Usage
Use try-catch for external services or expected failures:
// app/Services/Payment/PaymentService.php
namespace App\Services\Payment;
use App\Exceptions\Domain\PaymentFailedException;
use Stripe\Exception\CardException;
use Stripe\PaymentIntent;
class PaymentService
{
public function processPayment(array $data)
{
try {
$paymentIntent = PaymentIntent::create([
'amount' => $data['amount'],
'currency' => 'usd',
'payment_method' => $data['payment_method'],
]);
return $paymentIntent;
} catch (CardException $e) {
// Card was declined
throw new PaymentFailedException(
'Payment failed: ' . $e->getMessage(),
['error_code' => $e->getError()->code]
);
} catch (\Exception $e) {
// Other errors
throw new PaymentFailedException(
'An error occurred while processing your payment.',
['original_error' => $e->getMessage()]
);
}
}
}Validation
Form Request Validation
Use Laravel Form Requests for validation:
// app/Http/Requests/User/StoreUserRequest.php
namespace App\Http\Requests\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:8|confirmed',
'role' => 'nullable|in:user,admin,moderator',
];
}
public function messages(): array
{
return [
'name.required' => 'Please provide a name.',
'email.unique' => 'This email address is already registered.',
'password.min' => 'Password must be at least 8 characters long.',
];
}
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(
response()->json([
'error' => [
'code' => 'VALIDATION_ERROR',
'message' => 'The given data was invalid.',
'details' => $validator->errors(),
]
], 422)
);
}
}Custom Validation Rules
// app/Rules/StrongPassword.php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class StrongPassword implements Rule
{
public function passes($attribute, $value): bool
{
return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/', $value);
}
public function message(): string
{
return 'The :attribute must contain at least one uppercase letter, one lowercase letter, one number, and one special character.';
}
}Logging
Log Levels
Use appropriate log levels:
use Illuminate\Support\Facades\Log;
// Debug - Detailed debug information
Log::debug('User query executed', ['query' => $query]);
// Info - Informational messages
Log::info('User logged in', ['user_id' => $user->id]);
// Notice - Normal but significant events
Log::notice('User updated profile', ['user_id' => $user->id]);
// Warning - Exceptional occurrences that are not errors
Log::warning('API rate limit approaching', ['user_id' => $user->id, 'remaining' => 10]);
// Error - Runtime errors that don't require immediate action
Log::error('Payment processing failed', [
'user_id' => $user->id,
'error' => $e->getMessage()
]);
// Critical - Critical conditions
Log::critical('Database connection lost', ['exception' => $e]);
// Alert - Action must be taken immediately
Log::alert('Disk space critically low', ['available' => '1GB']);
// Emergency - System is unusable
Log::emergency('Application crashed', ['exception' => $e]);Contextual Logging
Always include relevant context:
try {
$user = $this->userRepository->create($data);
Log::info('User created successfully', [
'user_id' => $user->id,
'email' => $user->email,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
} catch (\Exception $e) {
Log::error('Failed to create user', [
'data' => $data,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'ip_address' => request()->ip(),
]);
throw $e;
}Request ID for Tracking
Add request ID to all logs for tracing:
// app/Http/Middleware/AssignRequestId.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
class AssignRequestId
{
public function handle($request, Closure $next)
{
$requestId = Str::uuid()->toString();
$request->headers->set('X-Request-ID', $requestId);
Log::withContext([
'request_id' => $requestId,
'user_id' => auth()->id(),
]);
$response = $next($request);
$response->headers->set('X-Request-ID', $requestId);
return $response;
}
}Error Monitoring
Third-Party Services
Integrate with error monitoring services:
// app/Exceptions/Handler.php
use Sentry\Laravel\Integration;
public function register(): void
{
$this->reportable(function (Throwable $e) {
if (app()->bound('sentry')) {
app('sentry')->captureException($e);
}
});
}Custom Error Reporting
// app/Services/ErrorReportingService.php
namespace App\Services;
class ErrorReportingService
{
public function report(\Throwable $e, array $context = []): void
{
$data = [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'context' => $context,
'request' => [
'url' => request()->url(),
'method' => request()->method(),
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
],
'user' => auth()->user()?->only(['id', 'email']),
'timestamp' => now()->toIso8601String(),
];
// Send to monitoring service
Log::error('Application error', $data);
}
}Best Practices
1. Use Specific Exceptions
Create specific exception classes for different error scenarios:
// Good
throw new UserNotFoundException($userId);
throw new PaymentFailedException('Card declined');
// Bad
throw new \Exception('User not found');2. Include Context
Always include relevant context in exceptions:
// Good
throw new ValidationException(
['email' => ['Email already exists']],
'User registration failed'
);
// Bad
throw new ValidationException([]);3. Don't Swallow Exceptions
Avoid empty catch blocks:
// Bad
try {
$this->process();
} catch (\Exception $e) {
// Silent failure
}
// Good
try {
$this->process();
} catch (\Exception $e) {
Log::error('Process failed', ['error' => $e->getMessage()]);
throw new ProcessingException('Failed to process', [], $e);
}4. Use Type Hints
Catch specific exception types:
// Good
try {
$user = User::findOrFail($id);
} catch (ModelNotFoundException $e) {
throw new UserNotFoundException($id);
}
// Avoid
catch (\Exception $e) {
// Too broad
}5. Log Before Throwing
Log errors before throwing them up:
try {
$result = $this->externalService->call();
} catch (ServiceException $e) {
Log::error('External service failed', [
'service' => 'payment',
'error' => $e->getMessage()
]);
throw new PaymentServiceException(
'Payment service is unavailable',
['original_error' => $e->getMessage()]
);
}6. Don't Expose Sensitive Data
Never expose sensitive information in error messages:
// Bad
throw new \Exception("Database connection failed: host=prod-db, user=admin, pass=secret");
// Good
throw new DatabaseException('Database connection failed');
// Log full details securely
Log::error('Database connection failed', [
'host' => config('database.host'),
'error' => $e->getMessage()
]);7. Return Consistent Formats
Always use the standard error format:
// Good - Consistent
return response()->json([
'error' => [
'code' => 'USER_NOT_FOUND',
'message' => 'User not found',
'details' => ['user_id' => $id]
]
], 404);
// Bad - Inconsistent
return response()->json([
'success' => false,
'error_message' => 'User not found'
], 404);Testing Error Handling
Test Exception Throwing
// tests/Unit/Services/UserServiceTest.php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Services\User\UserService;
use App\Exceptions\Domain\UserNotFoundException;
class UserServiceTest extends TestCase
{
public function test_throws_exception_when_user_not_found()
{
$service = new UserService();
$this->expectException(UserNotFoundException::class);
$service->getUser(999);
}
}Test Error Responses
// tests/Feature/Api/V1/UserTest.php
namespace Tests\Feature\Api\V1;
use Tests\TestCase;
class UserTest extends TestCase
{
public function test_returns_404_when_user_not_found()
{
$response = $this->getJson('/api/v1/users/999');
$response->assertStatus(404)
->assertJson([
'error' => [
'code' => 'RESOURCE_NOT_FOUND',
'message' => 'The requested User was not found.',
]
]);
}
public function test_returns_422_on_validation_error()
{
$response = $this->postJson('/api/v1/users', [
'email' => 'invalid-email'
]);
$response->assertStatus(422)
->assertJson([
'error' => [
'code' => 'VALIDATION_ERROR',
]
])
->assertJsonStructure([
'error' => [
'code',
'message',
'details' => ['email']
]
]);
}
}Related Documentation
- API Design Standards - API design principles
- Logging - Logging standards
- API Overview - API response formats
- Coding Standards - Code style guide