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:

  1. The Accept header contains application/json
  2. The Content-Type header contains application/json
  3. 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