Skip to main content

Error Handling Guide

This guide covers error handling patterns, exception types, and best practices for returning consistent error responses in your REST API.

Table of Contents

Overview

The reference architecture uses the ByJG RestServer exception system to automatically convert exceptions into appropriate HTTP responses.

Exception Hierarchy

Throwable
└── Exception
└── Error*Exception (ByJG\RestServer\Exception)
├── Error400Exception (Bad Request)
├── Error401Exception (Unauthorized)
├── Error403Exception (Forbidden)
├── Error404Exception (Not Found)
├── Error405Exception (Method Not Allowed)
├── Error409Exception (Conflict)
├── Error415Exception (Unsupported Media Type)
├── Error422Exception (Unprocessable Entity)
├── Error429Exception (Too Many Requests)
└── Error500Exception (Internal Server Error)

HTTP Exception Classes

Available Exceptions

ExceptionHTTP CodeUse Case
Error400Exception400Invalid request data, validation failures
Error401Exception401Authentication required or failed
Error403Exception403Authenticated but not authorized
Error404Exception404Resource not found
Error405Exception405HTTP method not allowed
Error409Exception409Conflict (duplicate resource)
Error415Exception415Wrong content-type
Error422Exception422Validation error (semantic)
Error429Exception429Rate limit exceeded
Error500Exception500Server error

Import Statements

use ByJG\RestServer\Exception\Error400Exception;
use ByJG\RestServer\Exception\Error401Exception;
use ByJG\RestServer\Exception\Error403Exception;
use ByJG\RestServer\Exception\Error404Exception;
use ByJG\RestServer\Exception\Error409Exception;
use ByJG\RestServer\Exception\Error422Exception;
use ByJG\RestServer\Exception\Error429Exception;
use ByJG\RestServer\Exception\Error500Exception;

Throwing Exceptions

400 - Bad Request

Use for invalid input data:

public function createProduct(array $payload)
{
if (empty($payload['name'])) {
throw new Error400Exception('Product name is required');
}

if ($payload['price'] < 0) {
throw new Error400Exception('Price cannot be negative');
}

if (!in_array($payload['status'], ['active', 'inactive'])) {
throw new Error400Exception(
'Status must be either "active" or "inactive"'
);
}

return $this->create($payload);
}

401 - Unauthorized

Authentication is required but missing or invalid:

public function validateToken(string $token): User
{
if (empty($token)) {
throw new Error401Exception('Authentication token required');
}

try {
$decoded = JwtWrapper::decode($token);
} catch (\Exception $e) {
throw new Error401Exception('Invalid or expired token');
}

return $this->getUserFromToken($decoded);
}

Note: The RequireAuthenticated attribute automatically throws this exception.

403 - Forbidden

User is authenticated but lacks permissions:

public function deleteProduct(int $productId, User $user): void
{
if ($user->getRole() !== User::ROLE_ADMIN) {
throw new Error403Exception('Only administrators can delete products');
}

$this->delete($productId);
}

Note: The RequireRole attribute automatically throws this exception.

404 - Not Found

Resource doesn't exist:

public function getProduct(int $id): Product
{
$product = $this->repository->get($id);

if ($product === null) {
throw new Error404Exception("Product with ID {$id} not found");
}

return $product;
}

// Or use getOrFail() helper
public function getProduct(int $id): Product
{
// Automatically throws Error404Exception if not found
return $this->getOrFail($id);
}

409 - Conflict

Resource already exists (duplicate):

public function createUser(array $payload): User
{
$existing = $this->repository->findByEmail($payload['email']);

if ($existing !== null) {
throw new Error409Exception(
"User with email {$payload['email']} already exists"
);
}

return $this->create($payload);
}

422 - Unprocessable Entity

Semantically incorrect data:

public function scheduleDelivery(int $orderId, string $date): void
{
$order = $this->getOrFail($orderId);

if ($order->getStatus() !== Order::STATUS_CONFIRMED) {
throw new Error422Exception(
'Cannot schedule delivery for unconfirmed order'
);
}

$deliveryDate = strtotime($date);
if ($deliveryDate < time()) {
throw new Error422Exception(
'Delivery date cannot be in the past'
);
}

$order->setDeliveryDate($date);
$this->save($order);
}

429 - Rate Limit Exceeded

Too many requests:

public function checkRateLimit(string $clientId): void
{
$requests = $this->countRecentRequests($clientId);

if ($requests > 100) {
throw new Error429Exception(
'Rate limit exceeded. Maximum 100 requests per minute.'
);
}
}

500 - Internal Server Error

Unexpected server errors:

public function processPayment(int $orderId): void
{
try {
$paymentGateway = Config::get(PaymentGateway::class);
$paymentGateway->charge($orderId);
} catch (\Exception $e) {
// Log the actual error
error_log("Payment processing failed: {$e->getMessage()}");

// Return generic error to client
throw new Error500Exception(
'Payment processing failed. Please try again later.'
);
}
}

Error Response Format

All exceptions are automatically converted to JSON responses:

Standard Error Response

{
"error": "Bad Request",
"message": "Product name is required"
}

Error Response with Details

{
"error": "Validation Failed",
"message": "Multiple validation errors occurred",
"details": {
"name": "Product name is required",
"price": "Price must be a positive number",
"category": "Invalid category selected"
}
}

REST Controller Example

#[OA\Post(path: "/products", tags: ["Products"])]
#[OA\Response(
response: 200,
description: "Product created successfully"
)]
#[OA\Response(
response: 400,
description: "Validation error",
content: new OA\JsonContent(ref: "#/components/schemas/error")
)]
#[OA\Response(
response: 409,
description: "Product already exists",
content: new OA\JsonContent(ref: "#/components/schemas/error")
)]
#[ValidateRequest]
public function createProduct(HttpResponse $response, HttpRequest $request): void
{
try {
$payload = ValidateRequest::getPayload();
$productService = Config::get(ProductService::class);
$product = $productService->create($payload);

$response->write(["id" => $product->getId()]);
} catch (Error400Exception $e) {
// Automatically returns 400 with error message
throw $e;
} catch (Error409Exception $e) {
// Automatically returns 409 with error message
throw $e;
}
}

Custom Exceptions

Creating Domain-Specific Exceptions

<?php

namespace RestReferenceArchitecture\Exception;

use ByJG\RestServer\Exception\Error400Exception;

class InsufficientStockException extends Error400Exception
{
public function __construct(string $productName, int $available, int $requested)
{
parent::__construct(
"Insufficient stock for '{$productName}'. " .
"Available: {$available}, Requested: {$requested}"
);
}
}

Usage

use RestReferenceArchitecture\Exception\InsufficientStockException;

public function createOrder(array $orderData): Order
{
foreach ($orderData['items'] as $item) {
$product = $this->productService->getOrFail($item['product_id']);

if ($product->getStock() < $item['quantity']) {
throw new InsufficientStockException(
$product->getName(),
$product->getStock(),
$item['quantity']
);
}
}

return $this->create($orderData);
}

Exception with Additional Data

<?php

namespace RestReferenceArchitecture\Exception;

use ByJG\RestServer\Exception\Error422Exception;

class ValidationException extends Error422Exception
{
protected array $errors;

public function __construct(array $errors)
{
$this->errors = $errors;
$message = "Validation failed: " . implode(', ', array_keys($errors));
parent::__construct($message);
}

public function getErrors(): array
{
return $this->errors;
}

public function toArray(): array
{
return [
'error' => 'Validation Failed',
'message' => $this->getMessage(),
'details' => $this->errors
];
}
}

Usage

public function create(array $payload)
{
$errors = [];

if (empty($payload['name'])) {
$errors['name'] = 'Name is required';
}

if (empty($payload['email'])) {
$errors['email'] = 'Email is required';
} elseif (!filter_var($payload['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'Invalid email format';
}

if (!empty($errors)) {
throw new ValidationException($errors);
}

return parent::create($payload);
}

Validation Errors

OpenAPI Validation

The ValidateRequest attribute automatically validates against OpenAPI schema:

#[OA\Post(path: "/products", tags: ["Products"])]
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ["name", "price"],
properties: [
new OA\Property(property: "name", type: "string", minLength: 3),
new OA\Property(property: "price", type: "number", minimum: 0)
]
)
)]
#[ValidateRequest]
public function createProduct(HttpResponse $response, HttpRequest $request): void
{
// If validation fails, Error400Exception is automatically thrown
$payload = ValidateRequest::getPayload();

$productService = Config::get(ProductService::class);
$product = $productService->create($payload);

$response->write(["id" => $product->getId()]);
}

Validation Error Response

{
"error": "Bad Request",
"message": "Validation failed: property 'name' must be at least 3 characters"
}

Service-Level Validation

Add business rule validation in services:

class ProductService extends BaseService
{
public function create(array $payload)
{
// Schema validation already passed (via ValidateRequest)
// Now apply business rules

$this->validateUniqueSKU($payload['sku'] ?? null);
$this->validateCategory($payload['category_id'] ?? null);
$this->validatePriceRange($payload['price'] ?? null);

return parent::create($payload);
}

protected function validateUniqueSKU(?string $sku): void
{
if ($sku && $this->repository->existsBySKU($sku)) {
throw new Error409Exception("Product with SKU '{$sku}' already exists");
}
}

protected function validateCategory(?int $categoryId): void
{
if ($categoryId) {
$categoryService = Config::get(CategoryService::class);
try {
$categoryService->getOrFail($categoryId);
} catch (Error404Exception $e) {
throw new Error400Exception("Invalid category ID: {$categoryId}");
}
}
}

protected function validatePriceRange(?float $price): void
{
if ($price !== null) {
if ($price < 0) {
throw new Error400Exception('Price cannot be negative');
}
if ($price > 1000000) {
throw new Error400Exception('Price exceeds maximum allowed value');
}
}
}
}

Global Error Handling

Custom Error Handler

Configure in config/03-api/01-rest.php:

use ByJG\RestServer\ErrorHandler\ErrorHandler;
use ByJG\RestServer\HttpResponse;

$errorHandler = new ErrorHandler();

// Log all errors
$errorHandler->addHandler(function(\Throwable $ex, $request, HttpResponse $response) {
error_log(sprintf(
"[%s] %s: %s in %s:%d",
date('Y-m-d H:i:s'),
get_class($ex),
$ex->getMessage(),
$ex->getFile(),
$ex->getLine()
));

// Let default handler format the response
return null;
});

// Customize error response format
$errorHandler->addHandler(function(\Throwable $ex, $request, HttpResponse $response) {
$statusCode = $ex->getCode() >= 400 && $ex->getCode() < 600
? $ex->getCode()
: 500;

return [
'success' => false,
'error' => [
'code' => $statusCode,
'message' => $ex->getMessage(),
'type' => (new \ReflectionClass($ex))->getShortName(),
'timestamp' => date('c')
]
];
});

return [
'errorHandler' => fn() => $errorHandler
];

Environment-Specific Error Details

$errorHandler->addHandler(function(\Throwable $ex, $request, HttpResponse $response) {
$isDevelopment = Config::get('environment') === 'dev';

$error = [
'error' => $ex->getMessage(),
'code' => $ex->getCode()
];

// Include stack trace in development
if ($isDevelopment) {
$error['file'] = $ex->getFile();
$error['line'] = $ex->getLine();
$error['trace'] = $ex->getTraceAsString();
}

return $error;
});

Error Logging

Simple Logging

public function processOrder(int $orderId): void
{
try {
// Process order...
} catch (\Exception $e) {
error_log("Order processing failed for order {$orderId}: {$e->getMessage()}");
throw new Error500Exception('Order processing failed');
}
}

Structured Logging

use Psr\Log\LoggerInterface;

class OrderService extends BaseService
{
protected LoggerInterface $logger;

public function __construct(
OrderRepository $repository,
LoggerInterface $logger
) {
parent::__construct($repository);
$this->logger = $logger;
}

public function processOrder(int $orderId): void
{
try {
// Process order...
} catch (\Exception $e) {
$this->logger->error('Order processing failed', [
'order_id' => $orderId,
'exception' => get_class($e),
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);

throw new Error500Exception('Order processing failed');
}
}
}

Context-Rich Logging

public function updateProduct(int $productId, array $payload): Product
{
$this->logger->info('Product update initiated', [
'product_id' => $productId,
'fields' => array_keys($payload)
]);

try {
$product = $this->update($payload);

$this->logger->info('Product updated successfully', [
'product_id' => $productId
]);

return $product;

} catch (Error404Exception $e) {
$this->logger->warning('Product not found', [
'product_id' => $productId
]);
throw $e;

} catch (\Exception $e) {
$this->logger->error('Product update failed', [
'product_id' => $productId,
'error' => $e->getMessage()
]);
throw new Error500Exception('Failed to update product');
}
}

Best Practices

1. Use Appropriate HTTP Status Codes

// Good - Specific status codes
throw new Error409Exception('Email already exists'); // 409 Conflict
throw new Error422Exception('Cannot cancel shipped order'); // 422 Unprocessable

// Bad - Generic 400 for everything
throw new Error400Exception('Email already exists');
throw new Error400Exception('Cannot cancel shipped order');

2. Provide Clear Error Messages

// Good - Descriptive and actionable
throw new Error400Exception(
'Invalid date format. Expected YYYY-MM-DD, got: ' . $date
);

// Bad - Vague
throw new Error400Exception('Invalid input');

3. Don't Expose Internal Details in Production

// Good - Generic message to client, detailed log
try {
$this->paymentGateway->charge($amount);
} catch (\Exception $e) {
error_log("Payment gateway error: " . $e->getMessage());
throw new Error500Exception('Payment processing failed');
}

// Bad - Exposes internal implementation
throw new Error500Exception(
'MySQL connection to 192.168.1.100 failed: ' . $e->getMessage()
);

4. Use Custom Exceptions for Domain Logic

// Good - Self-documenting exception
throw new InsufficientStockException($product->getName(), 5, 10);

// Bad - Generic exception
throw new Error400Exception('Not enough stock');

5. Validate at Multiple Layers

// Layer 1: OpenAPI schema validation (syntax)
#[ValidateRequest]

// Layer 2: Service validation (business rules)
public function create(array $payload) {
$this->validateBusinessRules($payload);
return parent::create($payload);
}

// Layer 3: Model validation (data integrity)
public function save($model): void {
$model->validate();
parent::save($model);
}

6. Document Errors in OpenAPI

#[OA\Post(path: "/products", tags: ["Products"])]
#[OA\Response(response: 200, description: "Success")]
#[OA\Response(
response: 400,
description: "Invalid input data",
content: new OA\JsonContent(ref: "#/components/schemas/error")
)]
#[OA\Response(
response: 409,
description: "Product SKU already exists",
content: new OA\JsonContent(ref: "#/components/schemas/error")
)]
public function createProduct(...) { }

7. Use getOrFail() Instead of Manual Checks

// Good - Concise
$product = $this->getOrFail($id);

// Bad - Verbose
$product = $this->get($id);
if ($product === null) {
throw new Error404Exception('Product not found');
}

8. Catch Specific Exceptions

// Good - Handle specific cases
try {
$product = $this->productService->create($payload);
} catch (Error409Exception $e) {
// Handle duplicate
} catch (Error400Exception $e) {
// Handle validation error
}

// Bad - Generic catch-all
try {
$product = $this->productService->create($payload);
} catch (\Exception $e) {
throw new Error500Exception('Something went wrong');
}