Consistent API error handling in Laravel

David Carr

Laravel Framework API

Introduction

In API development, providing clear and structured error responses is crucial for a seamless developer experience. This blog post explores a custom exception handling approach that ensures consistent JSON error responses, making it easier for API consumers to handle errors efficiently.

The Problem with Inconsistent Error Responses

By default, Laravel returns different structures for different types of errors. This inconsistency can make error handling unpredictable and difficult to manage. A standardized approach helps developers anticipate error structures and build robust integrations.

Here are some examples illustrating the problem with inconsistent error responses in Laravel:

Authentication Failure

When a user is unauthenticated, Laravel returns a response like this:

{
  "message": "Unauthenticated."
}

However, this structure lacks a clear error type or additional details that could help API consumers handle the response properly.

Authorization Failure

If a user is authorized incorrectly, Laravel throws an AuthorizationException, resulting in:

{
  "message": "This action is unauthorized."
}

Model Not Found

When an API tries to fetch a resource that doesn’t exist, a ModelNotFoundException generates:

{
  "message": "No query results for model [User] 123."
}

Validation Error

For validation failures, Laravel returns a completely different structure:

{
  "message": "The given data was invalid.",
  "errors": {
    "email": [
      "The email field is required."
    ],
    "password": [
      "The password must be at least 8 characters."
    ]
  }
}

Unlike previous responses, this one includes an errors object, making it structurally different from the rest.

One Solution: A Unified Error Response Structure

To create a consistent API error handling mechanism, we define each error response with:

  • status: The HTTP status code.
  • errors: An array of errors, each containing:
    • type: The class name of the exception.
    • message: A descriptive error message.

Example Responses

Unauthenticated

{
  "status": 401,
  "type": "AuthenticationException",
  "error": "Unauthenticated"
}

Validation Failure

{
  "status": 422,
  "type": "ValidationException",
  "errors": [
    {
      "name": "The name field is required."
    },
    {
      "enabled": "The enabled field is required."
    },
    {
      "is_meal": "The is meal field is required."
    }
  ]
}

Not Found (404)

{
  "status": 404,
  "type": "NotFoundHttpException",
  "error": "Not Found /api/v1/product"
}

Implementing Custom Exception Handling

In bootstrap/app.php, we define a custom exception handler that processes errors and returns structured responses. Any unhandled exceptions default to a 500 response with a predefined format.

use App\Exceptions\ApiExceptions;
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (Exception $e, Request $request): ?JsonResponse {
        $className = get_class($e);
        $handlers = ApiExceptions::$handlers;

        if (array_key_exists($className, $handlers)) {
            $method = $handlers[$className];
            return ApiExceptions::$method($e, $request);
        }

        return response()->json([
            'status' => 500,
            'type' => class_basename($e),
            'error' => $e->getMessage(),
        ], 500);
    });
})

Create a new class App/Exceptions/ApiExceptions.php

This class will class a method based on the exception type:

public static array $handlers = [
    AuthenticationException::class => 'handleAuthenticationException',
    AuthorizationException::class => 'handleAuthorizationException',
    ValidationException::class => 'handleValidationException',
    ModelNotFoundException::class => 'handleNotFoundException',
    NotFoundHttpException::class => 'handleNotFoundException',
    MethodNotAllowedHttpException::class => 'handleMethodNotAllowedHttpException',
    LazyLoadingViolationException::class => 'handleLazyLoadingViolationException',
    ThrottleRequestsException::class => 'handleThrottleRequestsException',
    AccessDeniedHttpException::class => 'handleAccessDeniedException',
    HttpException::class => 'handleHttpException',
    QueryException::class => 'handleQueryException',
    PostTooLargeException::class => 'handlePostTooLargeException',
];

Each method can invoke an entry in the logger and return a json response:

public static function handleAuthenticationException(AuthenticationException $e, Request $request): JsonResponse
{
    self::logException($e, $request, 'warning');

    return self::jsonResponse($e, Response::HTTP_UNAUTHORIZED);
}

private static function logException(Exception $e, Request $request, string $level = 'error'): void
{
    Log::$level(class_basename($e), [
        'message' => $e->getMessage(),
        'file' => $e->getFile(),
        'line' => $e->getLine(),
        'url' => $request->fullUrl(),
        'method' => $request->method(),
        'ip' => $request->ip(),
        'user_agent' => $request->header('User-Agent'),
    ]);
}

private static function jsonResponse(Exception $e, int $status, ?string $customMessage = null): JsonResponse
{
    return response()->json([
        'status' => $status,
        'type' => class_basename($e),
        'error' => $customMessage ?? $e->getMessage(),
    ], $status);
}

Putting it all together:

<?php

declare(strict_types=1);

namespace App\Exceptions;

use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\LazyLoadingViolationException;
use Illuminate\Database\QueryException;
use Illuminate\Http\Exceptions\PostTooLargeException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ApiExceptions
{
    /**
     * @var array<string, string>
     */
    public static array $handlers = [
        AuthenticationException::class => 'handleAuthenticationException',
        AuthorizationException::class => 'handleAuthorizationException',
        ValidationException::class => 'handleValidationException',
        ModelNotFoundException::class => 'handleNotFoundException',
        NotFoundHttpException::class => 'handleNotFoundException',
        MethodNotAllowedHttpException::class => 'handleMethodNotAllowedHttpException',
        LazyLoadingViolationException::class => 'handleLazyLoadingViolationException',
        ThrottleRequestsException::class => 'handleThrottleRequestsException',
        AccessDeniedHttpException::class => 'handleAccessDeniedException',
        HttpException::class => 'handleHttpException',
        QueryException::class => 'handleQueryException',
        PostTooLargeException::class => 'handlePostTooLargeException',
    ];

    public static function handleAuthenticationException(AuthenticationException $e, Request $request): JsonResponse
    {
        self::logException($e, $request, 'warning');

        return self::jsonResponse($e, Response::HTTP_UNAUTHORIZED);
    }

    public static function handleAuthorizationException(Exception $e, Request $request): JsonResponse
    {
        self::logException($e, $request, 'warning');

        return self::jsonResponse($e, Response::HTTP_FORBIDDEN);
    }

    public static function handleValidationException(ValidationException $e): JsonResponse
    {
        return self::jsonResponse($e, Response::HTTP_UNPROCESSABLE_ENTITY, $e->errors());
    }

    public static function handleNotFoundException(Exception $e, Request $request): JsonResponse
    {
        return self::jsonResponse($e, Response::HTTP_NOT_FOUND, 'Not Found '.$request->getRequestUri());
    }

    public static function handleMethodNotAllowedHttpException(Exception $e): JsonResponse
    {
        return self::jsonResponse($e, Response::HTTP_METHOD_NOT_ALLOWED);
    }

    public static function handleLazyLoadingViolationException(LazyLoadingViolationException $e, Request $request): JsonResponse
    {
        return self::jsonResponse($e, Response::HTTP_UNPROCESSABLE_ENTITY);
    }

    public static function handleThrottleRequestsException(ThrottleRequestsException $e, Request $request): JsonResponse
    {
        self::logException($e, $request, 'warning');

        return self::jsonResponse($e, Response::HTTP_TOO_MANY_REQUESTS, 'Too many requests, please slow down.');
    }

    public static function handleAccessDeniedException(AccessDeniedHttpException $e, Request $request): JsonResponse
    {
        self::logException($e, $request, 'warning');

        return self::jsonResponse($e, Response::HTTP_FORBIDDEN, 'Access denied.');
    }

    public static function handleHttpException(HttpException $e, Request $request): JsonResponse
    {
        self::logException($e, $request);

        return self::jsonResponse($e, $e->getStatusCode());
    }

    public static function handleQueryException(QueryException $e, Request $request): JsonResponse
    {
        self::logException($e, $request);

        return self::jsonResponse($e, Response::HTTP_INTERNAL_SERVER_ERROR, 'A database error occurred.');
    }

    public static function handlePostTooLargeException(PostTooLargeException $e, Request $request): JsonResponse
    {
        self::logException($e, $request, 'warning');

        return self::jsonResponse($e, Response::HTTP_REQUEST_ENTITY_TOO_LARGE, 'The uploaded file is too large.');
    }

    private static function logException(Exception $e, Request $request, string $level = 'error'): void
    {
        Log::$level(class_basename($e), [
            'message' => $e->getMessage(),
            'file' => $e->getFile(),
            'line' => $e->getLine(),
            'url' => $request->fullUrl(),
            'method' => $request->method(),
            'ip' => $request->ip(),
            'user_agent' => $request->header('User-Agent'),
        ]);
    }

    /**
     * @param  array<string, array<int, string>>|array<int, string>|string|null  $customMessage
     */
    private static function jsonResponse(Exception $e, int $status, null|array|string $customMessage = null): JsonResponse
    {
        return response()->json([
            'status' => $status,
            'type' => class_basename($e),
            $customMessage && is_array($customMessage) ? 'errors' : 'error' => $customMessage ?? $e->getMessage(),
        ], $status);
    }
}

Impact on Testing

With this structured error format, API tests need to verify the new structure. Instead of checking for arbitrary keys, it’s recommended to:

  • Assert the status code.
  • Validate a JSON fragment of the response.

Test ApiExceptions

All custom application code should always be tested, the following tests test each method using Pest

tests/Unit/ApiExceptionsTest.php

<?php

use App\Exceptions\ApiExceptions;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\LazyLoadingViolationException;
use Illuminate\Database\QueryException;
use Illuminate\Http\Exceptions\PostTooLargeException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

beforeEach(function () {
    $this->request = Mockery::mock(Request::class);
    $this->request->shouldReceive('fullUrl')->andReturn('http://test.com');
    $this->request->shouldReceive('method')->andReturn('GET');
    $this->request->shouldReceive('ip')->andReturn('127.0.0.1');
    $this->request->shouldReceive('header')->andReturn('Mozilla/5.0');
});

test('handleAuthenticationException', function () {
    $exception = new AuthenticationException('Unauthenticated');
    $response = ApiExceptions::handleAuthenticationException($exception, $this->request);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(401);
});

test('handleAuthorizationException', function () {
    $exception = new AuthorizationException('Forbidden');
    $response = ApiExceptions::handleAuthorizationException($exception, $this->request);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(403);
});

test('handleValidationException', function () {
    $exception = ValidationException::withMessages(['field' => ['Validation error']]);
    $response = ApiExceptions::handleValidationException($exception);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(422);
});

test('handleNotFoundException', function () {
    $request = Mockery::mock(Request::class);
    $request->shouldReceive('getRequestUri')->andReturn('/test-uri');

    $exception = new NotFoundHttpException('Not Found');
    $response = ApiExceptions::handleNotFoundException($exception, $request);

    $responseData = json_decode($response->getContent(), true);

    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(404)
        ->and($responseData)->toMatchArray([
            'status' => 404,
            'type' => 'NotFoundHttpException',
            'error' => 'Not Found /test-uri',
        ]);
});

test('handleMethodNotAllowedHttpException', function () {
    $exception = new MethodNotAllowedHttpException([], 'Method Not Allowed');
    $response = ApiExceptions::handleMethodNotAllowedHttpException($exception);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(405);
});

test('handleLazyLoadingViolationException', function () {
    $exception = Mockery::mock(LazyLoadingViolationException::class);
    $response = ApiExceptions::handleLazyLoadingViolationException($exception, $this->request);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(422);
});

test('handleThrottleRequestsException', function () {
    $exception = new ThrottleRequestsException('Too Many Requests');
    $response = ApiExceptions::handleThrottleRequestsException($exception, $this->request);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(429);
});

test('handleAccessDeniedException', function () {
    $exception = new AccessDeniedHttpException('Access Denied');
    $response = ApiExceptions::handleAccessDeniedException($exception, $this->request);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(403);
});

test('handleHttpException', function () {
    $exception = new HttpException(500, 'Internal Server Error');
    $response = ApiExceptions::handleHttpException($exception, $this->request);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(500);
});

test('handleQueryException', function () {
    $exception = Mockery::mock(QueryException::class);
    $response = ApiExceptions::handleQueryException($exception, $this->request);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(500);
});

test('handlePostTooLargeException', function () {
    $exception = new PostTooLargeException('Payload Too Large');
    $response = ApiExceptions::handlePostTooLargeException($exception, $this->request);
    expect($response)->toBeInstanceOf(JsonResponse::class)
        ->and($response->getStatusCode())->toBe(413);
});

Conclusion

Implementing structured exception handling improves API consistency, simplifies debugging, and enhances developer experience.

What do you think? Let’s discuss this further in the comments or on social media.

Read articles directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Copyright © 2006 - 2025 DC Blog - All rights reserved.