Pular para o conteúdo principal

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/<env>/03-api.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');
}