Exception Handling

Global Exception Handler

At some point, your application is going to throw an unhandled exception or shut down unexpectedly. When this happens, it would be nice to log details about the error and present a nicely-formatted response for the user. Aphiria provides GlobalExceptionHandler to do just this. It can be used to render exceptions for both HTTP and console applications, and is framework-agnostic.

Let's look at an example:

use Aphiria\Exceptions\GlobalExceptionHandler;
use Aphiria\Framework\Api\Exceptions\ProblemDetailsExceptionRenderer;

$exceptionRenderer = new ProblemDetailsExceptionRenderer();
$globalExceptionHandler = new GlobalExceptionHandler($exceptionRenderer);
// This is important - it's what registers the handler as the default handler in PHP
$globalExceptionHandler->registerWithPhp();

That's it. Now, whenever an unhandled error or exception is thrown, the global exception handler will catch it, log it, and render it. We'll go into more details on how to customize it below.

Problem Details Exception Renderer

ProblemDetailsExceptionRenderer is provided out of the box to simplify rendering problem details API responses for Aphiria applications. This renderer tries to create a response using the following steps:

  1. If a custom mapping exists for the thrown exception, it's used to create a problem details response
  2. If no mapping exists, a default 500 problem details response will be returned

By default, when the type field is null, it is automatically populated with a link to the HTTP status code contained in the problem details. You can override this behavior by extending ProblemDetailsExceptionRenderer and implementing your own getTypeFromException(). Similarly, the exception message is used as the title of the problem details. If you'd like to customize that, implement your own ProblemDetailsExceptionRenderer::getTitleFromException().

Custom Problem Details Mappings

You might not want all exceptions to result in a 500. For example, if you have a UserNotFoundException, you might want to map that to a 404. Here's how:

use Aphiria\Exceptions\GlobalExceptionHandler;
use Aphiria\Framework\Api\Exceptions\ProblemDetailsExceptionRenderer;
use Aphiria\Net\Http\HttpStatusCodes;

$exceptionRenderer = new ProblemDetailsExceptionRenderer();
$exceptionRenderer->mapExceptionToProblemDetails(UserNotFoundException::class, status: HttpStatusCodes::NOT_FOUND);
$globalExceptionHandler = new GlobalExceptionHandler($exceptionRenderer);
$globalExceptionHandler->registerWithPhp();

You can also specify other properties in the problem details:

$exceptionRenderer->mapExceptionToProblemDetails(
    OverdrawnException::class,
    type: 'https://example.com/errors/overdrawn',
    title: 'This account is overdrawn',
    detail: fn ($ex) => "Account {$ex->accountId} is overdrawn by {$ex->overdrawnAmount}",
    status: HttpStatusCodes::BAD_REQUEST,
    instance: fn ($ex) => "https://example.com/accounts/{$ex->accountId}/errors/{$ex->id}",
    extensions: fn ($ex) => ['overdrawnAmount' => $ex->overdrawnAmount]
);

Note: All parameters that accept closures with the thrown exception can also take hard-coded values.

When a ProblemDetails instance is serialized in a response, all of its extensions are serialized as top-level properties - not as key-value pairs under an extensions property.

Console Exception Renderer

ConsoleExceptionRenderer renders exceptions for Aphiria console applications. To render the exception, it goes through the following steps:

  1. If an output writer is registered for the thrown exception, it's used
  2. Otherwise, the exception message and stack trace is output to the console

Output Writers

Output writers allow you to write errors to the output and return a status code.

use Aphiria\Console\Output\IOutput;
use Aphiria\Console\StatusCodes;
use Aphiria\Exceptions\GlobalExceptionHandler;
use Aphiria\Framework\Console\Exceptions\ConsoleExceptionRenderer;

$exceptionRenderer = new ConsoleExceptionRenderer();
$exceptionRenderer->registerOutputWriter(
    DatabaseNotFound::class,
    function (DatabaseNotFound $ex, IOutput $output) {
        $output->writeln('<fatal>Contact a sysadmin</fatal>');

        return StatusCodes::FATAL;
    }
);

// You can also register many exceptions-to-output writers
$exceptionRenderer->registerManyOutputWriters([
    DatabaseNotFound::class => function (DatabaseNotFound $ex, IOutput $output) {
        $output->writeln('<fatal>Contact a sysadmin</fatal>');

        return StatusCodes::FATAL;
    },
    // ...
]);

$globalExceptionHandler = new GlobalExceptionHandler($exceptionRenderer);
$globalExceptionHandler->registerWithPhp();

Logging

By default, the global exception handler is compatible with any PSR-3 logger, such as Monolog. To use a specific logger, just pass it into the handler:

use Aphiria\Exceptions\GlobalExceptionHandler;
use Aphiria\Framework\Api\Exceptions\ApiExceptionRenderer;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LogLevel;

$exceptionRenderer = new ApiExceptionRenderer();
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('/etc/logs/errors.txt', LogLevel::DEBUG));
$globalExceptionHandler = new GlobalExceptionHandler($exceptionRenderer, $logger);
$globalExceptionHandler->registerWithPhp();

Exception Log Levels

It's possible to map certain exceptions to a PSR-3 log level. For example, if you have an exception that means your infrastructure might be down, you can cause it to log as an emergency.

use Aphiria\Exceptions\GlobalExceptionHandler;
use Aphiria\Framework\Api\Exceptions\ApiExceptionRenderer;
use Psr\Log\LogLevel;

$globalExceptionHandler = new GlobalExceptionHandler(new ApiExceptionRenderer());
$globalExceptionHandler->registerLogLevelFactory(
    DatabaseNotFoundException::class,
    fn (DatabaseNotFoundException $ex) => LogLevel::EMERGENCY
);

// You can also register multiple exceptions-to-log-level factories
$globalExceptionHandler->registerManyLogLevelFactories([
    DatabaseNotFoundException::class => fn (DatabaseNotFoundException $ex) => LogLevel::EMERGENCY,
    // ...
]);