Skip to content

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:

json
{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message",
    "details": {}
  }
}

Response Components

FieldTypeRequiredDescription
codestringYesMachine-readable error code (UPPER_SNAKE_CASE)
messagestringYesHuman-readable error description
detailsobjectNoAdditional context (validation errors, etc.)

Example Responses

Validation Error (422)

json
{
  "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)

json
{
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "The requested user was not found.",
    "details": {
      "resource": "User",
      "id": 123
    }
  }
}

Unauthorized Error (401)

json
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Authentication is required to access this resource.",
    "details": {
      "required": "Bearer token"
    }
  }
}

Forbidden Error (403)

json
{
  "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)

json
{
  "error": {
    "code": "INTERNAL_SERVER_ERROR",
    "message": "An unexpected error occurred. Please try again later.",
    "details": {
      "request_id": "req_abc123"
    }
  }
}

Error Codes

Standard Error Codes

CodeHTTP StatusDescription
VALIDATION_ERROR422Request data validation failed
RESOURCE_NOT_FOUND404Requested resource doesn't exist
UNAUTHORIZED401Authentication required or failed
FORBIDDEN403User lacks required permissions
AUTHENTICATION_FAILED401Invalid credentials
TOKEN_EXPIRED401Access token has expired
TOKEN_INVALID401Token is malformed or invalid
RATE_LIMIT_EXCEEDED429Too many requests
DUPLICATE_RESOURCE409Resource already exists
INVALID_REQUEST400Malformed request
METHOD_NOT_ALLOWED405HTTP method not supported
INTERNAL_SERVER_ERROR500Unexpected server error
SERVICE_UNAVAILABLE503Service temporarily unavailable
DATABASE_ERROR500Database 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_FUNDS

Exception Hierarchy

Base Exception

Create a base exception class for the application:

php
// 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

php
// 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

php
// 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

php
// 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

php
// 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

php
// 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:

php
// 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

php
// 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

php
// 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:

php
// 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:

php
// 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

php
// 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:

php
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:

php
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:

php
// 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:

php
// 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

php
// 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:

php
// 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:

php
// Good
throw new ValidationException(
    ['email' => ['Email already exists']],
    'User registration failed'
);

// Bad
throw new ValidationException([]);

3. Don't Swallow Exceptions

Avoid empty catch blocks:

php
// 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:

php
// 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:

php
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:

php
// 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:

php
// 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

php
// 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

php
// 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']
                ]
            ]);
    }
}

CPR - Clinical Patient Records