Error Handling
Melodic provides centralized exception handling through the ExceptionHandler class and ErrorHandlerMiddleware. Every uncaught exception is caught, logged, and converted into an appropriate HTTP response — JSON for API requests, styled HTML for browsers.
How It Works
The error handling flow wraps the entire middleware pipeline. When any exception escapes, the ErrorHandlerMiddleware catches it and delegates to the ExceptionHandler, which resolves a status code, logs the error, and builds a response.
Request → ErrorHandlerMiddleware → Pipeline → Controller
|
| catches Throwable
v
ExceptionHandler::handle()
|
+→ resolveStatusCode()
+→ logException()
+→ buildJsonResponse() or buildHtmlResponse()
Exception Hierarchy
Melodic provides a structured exception hierarchy rooted at HttpException for HTTP-specific errors. Each subclass sets the appropriate status code automatically.
| Class | Namespace | Status Code | Default Message |
|---|---|---|---|
HttpException |
Melodic\Http\Exception |
Any (constructor) | (none) |
BadRequestException |
Melodic\Http\Exception |
400 | Bad Request |
NotFoundException |
Melodic\Http\Exception |
404 | Not Found |
MethodNotAllowedException |
Melodic\Http\Exception |
405 | Method Not Allowed |
SecurityException |
Melodic\Security |
401 | (varies) |
HttpException Factory Methods
The base HttpException class provides static factory methods for common HTTP errors, so you can throw them without remembering status codes:
<?php
use Melodic\Http\Exception\HttpException;
// Static factories on HttpException
throw HttpException::notFound('User not found'); // 404
throw HttpException::forbidden('Access denied'); // 403
throw HttpException::badRequest('Invalid input'); // 400
throw HttpException::methodNotAllowed('Use POST'); // 405
// Or use the dedicated exception classes directly
use Melodic\Http\Exception\NotFoundException;
use Melodic\Http\Exception\BadRequestException;
throw new NotFoundException('Product not found');
throw new BadRequestException('Missing required field: email');
Status Code Mapping
The ExceptionHandler uses a match expression to determine the HTTP status code from the exception type. This runs in order of specificity:
| Exception Type | Status Code | Log Level |
|---|---|---|
HttpException (and subclasses) |
From getStatusCode() |
warning (4xx), error (5xx) |
SecurityException |
401 | warning |
JsonException |
400 | warning |
Any other Throwable |
500 | error |
Logging behavior. Client errors (4xx) are logged at warning level. Server errors (5xx) are logged at error level. Each log entry includes the HTTP method, request path, status code, and the exception object.
JSON vs HTML Response Detection
The exception handler automatically detects whether to respond with JSON or HTML. It checks three signals from the incoming request:
- The
Acceptheader containsapplication/json - The
Content-Typeheader containsapplication/json - The request path starts with
/api
If any of these conditions is true, the handler returns a JsonResponse. Otherwise, it returns an HTML error page.
JSON Error Response
// Standard JSON error response
{
"error": "User not found"
}
// Debug mode adds extra detail
{
"error": "User not found",
"exception": "Melodic\\Http\\Exception\\NotFoundException",
"file": "/app/src/Controllers/UserController.php",
"line": 42,
"trace": [
"#0 /app/src/Routing/RoutingMiddleware.php(55): ...",
"#1 /app/src/Http/Middleware/Pipeline.php(32): ..."
]
}
HTML Error Response
For browser requests, the handler renders a self-contained HTML page showing the status code and message. In debug mode, the page includes the exception class name, file location, and full stack trace.
Debug vs Production Mode
The ExceptionHandler has a debug flag that controls how much information is exposed in error responses.
| Behavior | Debug Mode | Production Mode |
|---|---|---|
| 5xx error message | Original exception message | An internal server error occurred. |
| 4xx error message | Original exception message | Original exception message |
| JSON: exception class, file, trace | Included | Omitted |
| HTML: stack trace panel | Included | Omitted |
Security note. Always disable debug mode in production. Stack traces, file paths, and exception class names can reveal internal application structure to attackers.
ExceptionHandler API
The Melodic\Error\ExceptionHandler class handles the actual exception-to-response conversion.
<?php
use Melodic\Error\ExceptionHandler;
use Melodic\Log\LoggerInterface;
// Create with a logger
$handler = new ExceptionHandler($logger);
// Enable debug output (disabled by default)
$handler->setDebug(true);
// Convert an exception to an HTTP response
$response = $handler->handle($exception, $request);
| Method | Signature | Description |
|---|---|---|
__construct |
(LoggerInterface $logger) |
Creates the handler with a logger for recording exceptions |
setDebug |
(bool $debug): void |
Enables or disables debug output in responses |
handle |
(Throwable $exception, Request $request): Response |
Resolves the status code, logs the error, and returns a JSON or HTML response |
ErrorHandlerMiddleware
The Melodic\Http\Middleware\ErrorHandlerMiddleware wraps the entire middleware pipeline in a try/catch block. It creates an ExceptionHandler internally and delegates to it when an exception is caught.
<?php
use Melodic\Http\Middleware\ErrorHandlerMiddleware;
use Melodic\Core\Application;
$app = new Application(__DIR__);
// Add as the outermost middleware so it catches everything
$app->addMiddleware(new ErrorHandlerMiddleware(
logger: $logger,
debug: true, // Set false in production
));
Middleware order matters. Register the ErrorHandlerMiddleware as the first middleware in the pipeline. This ensures it wraps all subsequent middleware and catches any exception thrown during request processing.
How the Middleware Works
The middleware implements the standard MiddlewareInterface. In its process() method, it delegates to the next handler inside a try/catch:
<?php
// Simplified view of ErrorHandlerMiddleware::process()
public function process(Request $request, RequestHandlerInterface $handler): Response
{
try {
return $handler->handle($request);
} catch (\Throwable $e) {
return $this->exceptionHandler->handle($e, $request);
}
}
Throwing Exceptions from Controllers
You can throw exceptions anywhere in your controller or service code. The ErrorHandlerMiddleware will catch them and return the correct response.
<?php
use Melodic\Controller\ApiController;
use Melodic\Http\Exception\NotFoundException;
use Melodic\Http\Exception\HttpException;
use Melodic\Http\Request;
use Melodic\Http\Response;
class UserController extends ApiController
{
public function show(int $id): Response
{
$user = $this->userService->getById($id);
if ($user === null) {
throw new NotFoundException("User {$id} not found");
}
return $this->json($user->toArray());
}
public function update(int $id, Request $request): Response
{
$data = $request->body();
if (empty($data)) {
throw HttpException::badRequest('Request body is empty');
}
$user = $this->userService->update($id, $data);
return $this->json($user->toArray());
}
}
Default Status Messages
When an exception has no message, the handler provides a fallback based on the status code:
| Status Code | Default Message |
|---|---|
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 405 | Method Not Allowed |
| 422 | Unprocessable Entity |
| 500 | Internal Server Error |