Things break. APIs crash. Databases go down. External services get rate-limited. Your API will have errors, and the priority is how you handle them.
Good error handling isn't just about returning the right HTTP status code. It's about:
- Consistency: A 400 error should always look the same.
- Debuggability: Logs should tell the story of what went wrong.
- Security: Don't leak internal details to the client.
- User Experience: Errors should be helpful, not cryptic.
In this chapter, we'll build a robust error handling and logging system that gives you visibility into what's happening in your API.
Centralized Exception Handling
When an exception happens anywhere in your app, you need to catch it, format it, and return a proper JSON response. Don't let stack traces leak to clients, that's dangerous and ugly.
Create an exception handler that catches everything and handles each type appropriately:
class ApiExceptionHandler extends Handler
{
public function render($request, Throwable $exception)
{
// Only format API responses
if (!$request->is('api/*')) {
return parent::render($request, $exception);
}
// Handle specific exceptions with appropriate status codes
return match (true) {
$exception instanceof ValidationException => $this->validation($exception),
$exception instanceof AuthenticationException => $this->authentication($exception),
$exception instanceof AuthorizationException => $this->authorization($exception),
$exception instanceof ModelNotFoundException => $this->notFound($exception),
$exception instanceof ThrottleRequestsException => $this->throttle($exception),
default => $this->generic($exception),
};
}
protected function validation(ValidationException $e): JsonResponse
{
return response()->json([
'error' => 'Validation failed',
'code' => 'VALIDATION_ERROR',
'fields' => $e->validator->errors(),
], 422);
}
protected function authentication(AuthenticationException $e): JsonResponse
{
return response()->json([
'error' => 'Invalid or expired token',
'code' => 'AUTHENTICATION_ERROR',
], 401);
}
protected function authorization(AuthorizationException $e): JsonResponse
{
return response()->json([
'error' => 'Insufficient permissions',
'code' => 'AUTHORIZATION_ERROR',
], 403);
}
protected function notFound(ModelNotFoundException $e): JsonResponse
{
return response()->json([
'error' => 'Resource not found',
'code' => 'NOT_FOUND',
], 404);
}
protected function throttle(ThrottleRequestsException $e): JsonResponse
{
return response()
->json([
'error' => 'Rate limit exceeded',
'code' => 'RATE_LIMIT_EXCEEDED',
], 429)
->header('Retry-After', $e->retryAfter);
}
protected function generic(Throwable $e): JsonResponse
{
// Log the error for debugging
Log::error('API Exception', [
'exception' => get_class($e),
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// Return generic error to client (no stack trace!)
return response()->json([
'error' => 'Server error',
'code' => 'INTERNAL_SERVER_ERROR',
], 500);
}
}
// Register in bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (Throwable $e, Request $request) {
return app(ApiExceptionHandler::class)->render($request, $e);
});
})
Notice: The generic handler logs the full error but returns a generic message to the client. Never leak stack traces to the outside world.
Custom Domain Exceptions
Instead of throwing generic exceptions, create domain-specific ones. This makes error handling clearer:
// Thrown when APIs fail
class DomainProviderException extends Exception
{
public static function invalidDomain(string $domain): self
{
return new self("Domain '{$domain}' is invalid");
}
public static function connectionFailed(string $provider): self
{
return new self("Failed to connect to {$provider}");
}
public static function quotaExceeded(): self
{
return new self('API quota exceeded');
}
}
// Thrown when business logic fails
class LicenseNotFound extends Exception
{
public static function create(string $key): self
{
return new self("License with key '{$key}' not found or already deleted");
}
}
// In exception renderer
if ($exception instanceof DomainProviderException) {
return response()->json([
'error' => 'External service failed',
'message' => $exception->getMessage(),
'code' => 'PROVIDER_ERROR',
], 503);
}
Custom exceptions are self-documenting. You see DomainProviderException and immediately know it's a provider problem, not your code.