Skip to content

Error Handling

samuelgfeller edited this page Mar 30, 2024 · 18 revisions

Introduction

PHP is an "exception-light" programming language, meaning that it doesn't throw exceptions for every little thing that goes wrong unlike other languages such as Python.

The chapter Errors and Exceptions from PHP The Right Way explains in great detail how PHP handles errors.

The following does not throw an exception but a warning: Warning: Undefined variable $foo. "Hello world" is still printed.

<?php

echo $foo;
echo 'Hello world';

PHP categorizes errors into different levels of severity, with three primary types of messages: errors, notices, and warnings. Each type corresponds to a specific constant, namely E_ERROR, E_NOTICE, and E_WARNING.
The list of all error constants can be found here.

Fatal errors

Errors with the severity E_ERROR are fatal and result in the termination of the script execution. They occur when there is a critical error, for e.g. a syntax error or undefined function.

Warnings and notices

Notices (E_NOTICE) and warnings (E_WARNING) don't halt the script execution. They produce a message and continue processing.

Notices are advisory messages that occur when PHP encounters something that might be an error, and warnings are non-fatal errors.

Error reporting

Displaying error details to the user exposes information about the structure of the application. In development, it's useful to see the errors to debug the application, but in production, it would present a big security risk.

By default, errors are logged in the webserver error.log file.

Here is a summary of the error configuration options:

error_reporting should always be set to E_ALL except if certain error severities should be ignored.

Development settings

To show every possible error during development, the php.ini file should be configured like this:

display_errors = On
display_startup_errors = On
error_reporting = E_ALL
log_errors = On

This will show the error details to the user either by the default PHP error message, the framework default error handler, the debugging tool (e.g. xdebug) or a custom error handler.

Production settings

In production, no error detail should ever be made visible to the user.
File: php.ini

display_errors = Off
display_startup_errors = Off
error_reporting = E_ALL
log_errors = On

This will take into account the errors, log them and display a generic error page but not show the details.

Custom Error Handler

The place where errors are logged, how they're logged, and the form in which development error details and production error pages are rendered can be customized with a custom error handler.

Slim already has an own error handler that can be used by adding $app->addErrorMiddleware(true, true, true); to the middleware stack.
It logs the errors and displays the details in a page designed by the framework.

In my opinion, the slim error handler can be improved in a few ways. Symfony has a great error page that displays the error details in a clear and pretty format. Laravel uses the library whoops, which also displays error details very well.

The priority for me in an error details page is that the important and relevant bits are highlighted to be seen clearly and recognized quickly. For example, the path to the file where the error occurred doesn't need to have the same font-size as the file name and the line number. And the part before src/ can be completely removed. Or in the stack trace, the files located inside the src directory should stand out next to those in the vendor directory.

I imagined something looking like this for an error details page:

Error-page

Configuration

With a custom error handler, it is important to note that errors that happen before the error handler is initialized or errors that happen inside the error handler will be handled by the server's default error handler.

This is why it is important to configure the php.ini file correctly even if the custom error handler is configured separately and overrides the php.ini settings with the display_error_details config value.

Options

The custom error handler uses the following configuration values.
They are initialized in defaults.php and should be changed in the environment specific config files.

File: config/defaults.php

// Error handler
$settings['error'] = [
    // Should be set to false in production.
    // When set to true, it will throw an ErrorException for notices and warnings.
    'display_error_details' => false,
    'log_errors' => true,
    'log_error_details' => true,
    // When true, the error response will be in json format for requests with content type application/json
    'json_error_response' => false,
];

Environment configuration

The Configuration chapter details how the different environments are specified and loaded.

Development

All errors, warnings and notices should be displayed in the browser while developing. Therefore, display_error_details should be true in the development config file.

File: config/env/env.dev.php

// Set false to show production error pages
$settings['dev'] = true;

// For the that the error is not caught by custom error handler (below)
ini_set('display_errors', $settings['dev'] ? '1' : '0');

// Display error details in browser and throw ErrorException for notices and warnings
$settings['error']['display_error_details'] = $settings['dev'];

Production

Errors, warnings and notices should be logged, but details shouldn't be shown to the client in production.

File: config/env/env.prod.php

// Display generic error page without details
$settings['error']['display_error_details'] = false;

Testing

During testing, every notice and warning should bring the test to a halt, so display_error_details should be true.

File: config/env/env.test.php

// Enable display_error_details for testing as this will throw an ErrorException for notices and warnings
$settings['error']['display_error_details'] = true;

Error middlewares

During development, notices and warnings should be addressed the same way as fatal errors. The script should stop the execution, the error logged and an error details page with stack trace displayed in the browser.
Symfony and Laravel are "exception-heavy" in debug mode for a long time already.

This means that notices and warnings are transformed into exceptions.

The slim error middleware then catches them, and the custom DefaultErrorHandler can display the error details.

Non fatal error handler middleware

The middleware that is responsible for turning notices and warnings into fatal errors in development is the NonFatalErrorHandlerMiddleware.
It does so by throwing an ErrorException (if display_error_details is true).

This middleware is also in charge of logging those non-fatal errors, as in production (when display_error_details is false), the ErrorException is not thrown for notices and warnings which means that the DefaultErrorHandler will not be called and can't log them.

The PHP function set_error_handler enables the custom handling of the non-fatal errors.

File: src/Application/Middleware/NonFatalErrorHandlerMiddleware.php

<?php

namespace App\Application\Middleware;

use ErrorException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;

final readonly class NonFatalErrorHandlerMiddleware implements MiddlewareInterface
{
    private bool $displayErrorDetails;
    private bool $logErrors;

    public function __construct(bool $displayErrorDetails, bool $logErrors, private LoggerInterface $logger)
    {
        $this->displayErrorDetails = $displayErrorDetails;
        $this->logErrors = $logErrors;
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        // set_error_handler only handles non-fatal errors. The function callback is not called by fatal errors.
        set_error_handler(
            function ($severity, $message, $file, $line) {
                // Don't throw exception if error reporting is turned off.
                // '&' checks if a particular error level is included in the result of error_reporting().
                if (error_reporting() & $severity) {
                    // Log non fatal errors if logging is enabled
                    if ($this->logErrors) {
                        // If error is warning
                        if ($severity === E_WARNING | E_CORE_WARNING | E_COMPILE_WARNING | E_USER_WARNING) {
                            $this->logger->warning("Warning [$severity] $message on line $line in file $file");
                        } // If error is non-fatal and is not a warning
                        else {
                            $this->logger->notice("Notice [$severity] $message on line $line in file $file");
                        }
                    }
                    if ($this->displayErrorDetails === true) {
                        // Throw ErrorException to stop script execution and have access to more error details
                        // Logging for fatal errors happens in DefaultErrorHandler.php
                        throw new ErrorException($message, 0, $severity, $file, $line);
                    }
                }
                return true;
            }
        );
        $response = $handler->handle($request);

        // Restore previous error handler in post-processing to satisfy PHPUnit 11 that checks for any
        // leftover error handlers https://github.com/sebastianbergmann/phpunit/pull/5619
        restore_error_handler();

        return $response;
    }
}

The NonFatalErrorHandlerMiddleware is instantiated in the DI-container with the display_error_details and log_errors config values as well as the logger instance.

File: config/container.php

use App\Application\Middleware\NonFatalErrorHandlerMiddleware;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;

return [

    // ...

    NonFatalErrorHandlerMiddleware::class => function (ContainerInterface $container) {
        $config = $container->get('settings')['error'];
        $logger = $container->get(LoggerInterface::class);
        return new NonFatalErrorHandlerMiddleware(
            (bool)$config['display_error_details'],
            (bool)$config['log_errors'],
            $logger,
        );
    },
];

This middleware is the second last in the middleware stack.

File: config/middleware.php

use Slim\App;
use App\Application\Middleware\NonFatalErrorHandlerMiddleware;

return function (App $app) {

    // ...
    
    $app->add(NonFatalErrorHandlerMiddleware::class);
    // ErrorMiddleware is the only middleware that comes after the NonFatalErrorHandlerMiddleware
};

Fatal error middleware

The error middleware is in charge of catching fatal errors and if there is a Throwable, invoke the custom DefaultErrorHandler.php.

Slim has an inbuilt ErrorMiddleware which is designed to call the error handler configured via the setDefaultErrorHandler method.

The ErrorMiddleware is instantiated in the container with the config values and logger.

File: config/container.php

use App\Application\Handler\DefaultErrorHandler;
use App\Application\Middleware\ErrorHandlerMiddleware;
use Psr\Container\ContainerInterface;
use Slim\Middleware\ErrorMiddleware;
use Psr\Log\LoggerInterface;
use Slim\App;

return [

    // ...
    
    // Set error handler to custom DefaultErrorHandler
    ErrorMiddleware::class => function (ContainerInterface $container) {
        $config = $container->get('settings')['error'];
        $app = $container->get(App::class);

        $logger = $container->get(LoggerInterface::class);

        $errorMiddleware = new ErrorMiddleware(
            $app->getCallableResolver(),
            $app->getResponseFactory(),
            (bool)$config['display_error_details'],
            (bool)$config['log_errors'],
            (bool)$config['log_error_details'],
            $logger
        );

        // Set the custom error handler
        $errorMiddleware->setDefaultErrorHandler(
            $container->get(\App\Application\ErrorHandler\DefaultErrorHandler::class)
        );

        return $errorMiddleware;
    },
];

The ErrorMiddleware must be added at the very end of the middleware stack. It is important that it is the last middleware because, as explained in the Middleware chapter, that last middleware of the stack will be the first to be executed.

File: config/middleware.php

use Slim\App;
use App\Application\Middleware\ErrorHandlerMiddleware;
use Slim\Middleware\ErrorMiddleware;

return function (App $app) {

    // ...

    $app->add(NonFatalErrorHandlerMiddleware::class); 
    $app->add(ErrorMiddleware::class);
};

It is essential that ErrorMiddleware is executed first because the goal is to cover as many errors as possible with the custom error handler. The process function of ErrorMiddleware contains a try catch block and if this middleware is the last in the stack, meaning the first called, it will catch every error that might happen later.
Everything that happens before the process function of ErrorMiddleware will not be caught and handled by the error handler set with Slim. See Order of execution.

This is also not the case if there is faulty code in the error handler itself or before the error handler is initialized.

File: vendor/slim/slim/Slim/Middleware/ErrorMiddleware.php

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
    try {
        return $handler->handle($request);
    } catch (Throwable $exception) {
        return $this->handleException($request, $exception);
    }
}

If, for instance, an error occurs during bootstrapping, it falls back to the default error handler from the web server or debugging tool; errors logged in the default error.log from the web server and the messages in the browser will either be the default PHP error message or rendered by the debugging tool.

Error handler

The custom error handler is in charge of logging the errors and rendering the error pages.

It is instantiated and then added to the ErrorMiddleware in the container configuration via the setDefaultErrorHandler method as seen in the previous section.
It's called when a Throwable is caught in the Slim ErrorMiddleware.

The error handler instance is called like a function in ErrorMiddleware with the required arguments. This means the __invoke magic method has to be implemented.

Custom DefaultErrorHandler

The first thing the DefaultErrorHandler does is log the error except if the exception is an instance of ErrorException because that would mean it's a warning / notice, and it's already logged in the NonFatalErrorHandlerMiddleware.

Then, it checks if the script is called via the command line. If it's the case, the error probably occurred during testing, and PHPUnit has its own error handler that is more suitable for the console as it supports syntax highlighting and a stack trace with hyperlinks. Therefore, the exception that's caught is thrown so that the PHPUnit error handler can handle it. The logger should be disabled for the entire application in the testing configuration, so the error isn't logged.

After that, the response is created and returned as JSON or rendered as an HTML page.

If the configuration value json_error_response is true and the request is of type JSON, the error details are returned in the JSON format. Else, the error details are rendered in an HTML page.

When the configuration value display_error_details is true, the error details (error message and stack trace) are rendered in the HTML template or included in the JSON response.

If $displayErrorDetails is false, a generic error page is displayed or added to the JSON response containing only the status code and reason phrase (e.g. 500 Internal Error or 404 Not found).

File: src/Application/ErrorHandler/DefaultErrorHandler.php

<?php

namespace App\Application\ErrorHandler;

use App\Domain\Validation\ValidationException;
use App\Infrastructure\Utility\Settings;
use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Selective\BasePath\BasePathDetector;
use Slim\Exception\HttpException;
use Slim\Interfaces\ErrorHandlerInterface;
use Slim\Views\PhpRenderer;
use Throwable;

final readonly class DefaultErrorHandler implements ErrorHandlerInterface
{
    private string $fileSystemPath;
    private bool $jsonErrorResponse;

    public function __construct(
        private PhpRenderer $phpRenderer,
        private ResponseFactoryInterface $responseFactory,
        private LoggerInterface $logger,
        private Settings $settings,
    ) {
        // The filesystem path to the project root folder will be removed in the error details page
        $this->fileSystemPath = 'C:\xampp\htdocs\\';
        // Return json error response if request is of type json
        $this->jsonErrorResponse = $this->settings->get('error')['json_error_response'] ?? true;
    }

    /**
     * @param ServerRequestInterface $request
     * @param Throwable $exception
     * @param bool $displayErrorDetails
     * @param bool $logErrors
     * @param bool $logErrorDetails
     *
     * @throws Throwable
     * @throws \ErrorException
     */
    public function __invoke(
        ServerRequestInterface $request,
        Throwable $exception,
        bool $displayErrorDetails,
        bool $logErrors,
        bool $logErrorDetails
    ): ResponseInterface {
        // Log error
        // If exception is an instance of ErrorException it means that the NonFatalErrorHandlerMiddleware
        // threw the exception for a warning or notice.
        // That middleware already logged the message, so it doesn't have to be done here.
        // The reason it is logged there is that if displayErrorDetails is false, ErrorException is not
        // thrown and the warnings and notices still have to be logged in prod.
        if ($logErrors && !$exception instanceof \ErrorException) {
            // Error with no stack trace https://stackoverflow.com/a/2520056/9013718
            $this->logger->error(
                sprintf(
                    'Error: [%s] %s File %s:%s , Method: %s, Path: %s',
                    $exception->getCode(),
                    $exception->getMessage(),
                    $exception->getFile(),
                    $exception->getLine(),
                    $request->getMethod(),
                    $request->getUri()->getPath()
                )
            );
        }

        // Error output if script is called via cli (e.g. testing)
        if (PHP_SAPI === 'cli') {
            // If the column is not found and the request is coming from the command line, it probably means
            // that the database schema.sql was not updated after a change.
            if ($exception instanceof \PDOException && str_contains($exception->getMessage(), 'Column not found')) {
                echo "Column not existing. Try running `composer schema:generate` in the console and run tests again. \n";
            }

            // Restore previous error handler when the exception has been thrown to satisfy PHPUnit v11
            // It is restored in the post-processing of the NonFatalErrorHandlerMiddleware, but the code doesn't
            // reach it when there's an exception (especially needed for tests expecting an exception).
            // Related PR: https://github.com/sebastianbergmann/phpunit/pull/5619
            restore_error_handler();

            // The exception is thrown to have the standard behaviour (important for testing).
            throw $exception;
        }

        // Create response
        $response = $this->responseFactory->createResponse();

        // Detect status code
        $statusCode = $this->getHttpStatusCode($exception);
        $response = $response->withStatus($statusCode);
        // Reason phrase is the text that describes the status code e.g. 404 => Not found
        $reasonPhrase = $response->getReasonPhrase();

        // If it's a HttpException it's safe to show the error message to the user
        $exceptionMessage = $exception instanceof HttpException ? $exception->getMessage() : null;

        // If the request is JSON and json error response is enabled, return a JSON response with the exception details
        if ($this->jsonErrorResponse === true &&
            str_contains($request->getHeaderLine('Content-Type'), 'application/json')
        ) {
            // If $displayErrorDetails is true, return exception details in json
            if ($displayErrorDetails === true) {
                $jsonErrorResponse = [
                    'status' => $statusCode,
                    'message' => $exception->getMessage(),
                    'file' => $exception->getFile(),
                    'line' => $exception->getLine(),
                    'trace' => $exception->getTrace(),
                ];
            } else {
                $jsonErrorResponse = [
                    'status' => $statusCode,
                    'message' => $exceptionMessage ?? 'An error occurred',
                ];
            }

            $response = $response->withHeader('Content-Type', 'application/json');
            $response->getBody()->write(json_encode($jsonErrorResponse, JSON_PARTIAL_OUTPUT_ON_ERROR));

            return $response;
        }

        $phpRendererAttributes['statusCode'] = $statusCode;
        $phpRendererAttributes['reasonPhrase'] = $reasonPhrase;
        $phpRendererAttributes['exceptionMessage'] = $exceptionMessage;
        $exceptionDetailsAttributes = $this->getExceptionDetailsAttributes($exception);

        // If $displayErrorDetails is true, display exception details
        if ($displayErrorDetails === true) {
            // Add exception details to template attributes
            $phpRendererAttributes = array_merge(
                $phpRendererAttributes,
                $exceptionDetailsAttributes
            );
            // The error-details template does not include the default layout,
            // so the base path to the project root folder is required to load assets
            $phpRendererAttributes['basePath'] = (new BasePathDetector($request->getServerParams()))->getBasePath();

            // Render template if the template path fails, the default webserver exception is shown
            return $this->phpRenderer->render($response, 'error/error-details.html.php', $phpRendererAttributes);
        }

        // Display generic error page
        return $this->phpRenderer->render($response, 'error/error-page.html.php', $phpRendererAttributes);
    }

    /**
     * Determine http status code.
     *
     * @param Throwable $exception The exception
     *
     * @return int The http code
     */
    private function getHttpStatusCode(Throwable $exception): int
    {
        // Default status code
        $statusCode = StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR; // 500

        // HttpExceptions have a status code
        if ($exception instanceof HttpException) {
            $statusCode = (int)$exception->getCode();
        }

        if ($exception instanceof ValidationException) {
            $statusCode = StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY; // 422
        }

        $file = basename($exception->getFile());
        if ($file === 'CallableResolver.php') {
            $statusCode = StatusCodeInterface::STATUS_NOT_FOUND; // 404
        }

        return $statusCode;
    }

    /**
     * Build the attribute array for the detailed error page.
     *
     * @param Throwable $exception
     *
     * @return array
     */
    private function getExceptionDetailsAttributes(Throwable $exception): array
    {
        $file = $exception->getFile();
        $lineNumber = $exception->getLine();
        $exceptionMessage = $exception->getMessage();
        $trace = $exception->getTrace();

        // If the exception is ErrorException, the css class is warning, otherwise it's error
        $severityCssClassName = $exception instanceof \ErrorException ? 'warning' : 'error';

        // Remove the filesystem path and make the path to the file that had the error smaller to increase readability
        $lastBackslash = strrpos($file, '\\');
        $mainErrorFile = substr($file, $lastBackslash + 1);
        $firstChunkFullPath = substr($file, 0, $lastBackslash + 1);
        // remove C:\xampp\htdocs\ and project name to keep only part starting with src\
        $firstChunkMinusFilesystem = str_replace($this->fileSystemPath, '', $firstChunkFullPath);
        // locate project name because it is right before the first backslash (after removing filesystem)
        $projectName = substr($firstChunkMinusFilesystem, 0, strpos($firstChunkMinusFilesystem, '\\') + 1);
        // remove project name from first chunk
        $pathToMainErrorFile = str_replace($projectName, '', $firstChunkMinusFilesystem);

        $traceEntries = [];

        foreach ($trace as $key => $t) {
            // Sometimes class, type, file and line not set e.g. pdfRenderer when var undefined in template
            $t['class'] = $t['class'] ?? '';
            $t['type'] = $t['type'] ?? '';
            $t['file'] = $t['file'] ?? '';
            $t['line'] = $t['line'] ?? '';
            // remove everything from file path before the last \
            $fileWithoutPath = $this->removeEverythingBeforeLastBackslash($t['file']);
            // remove everything from class before last \
            $classWithoutPath = $this->removeEverythingBeforeLastBackslash($t['class']);
            // if the file path doesn't contain "vendor", a css class is added to highlight it
            $nonVendorFileClass = !str_contains($t['file'], 'vendor') ? 'non-vendor' : '';
            // if file and class path don't contain vendor, add "non-vendor" css class to add highlight on class
            $classIsVendor = str_contains($t['class'], 'vendor');
            $nonVendorFunctionCallClass = !empty($nonVendorFileClass) && !$classIsVendor ? 'non-vendor' : '';
            // Get function arguments
            $args = [];
            foreach ($t['args'] ?? [] as $argKey => $argument) {
                // Get argument as string not longer than 15 characters
                $args[$argKey]['truncated'] = $this->getTraceArgumentAsTruncatedString($argument);
                // Get full length of argument as string
                $fullArgument = $this->getTraceArgumentAsString($argument);
                // Replace double backslash with single backslash
                $args[$argKey]['detailed'] = str_replace('\\\\', '\\', $fullArgument);
            }
            $traceEntries[$key]['args'] = $args;
            // If the file is outside vendor class, add "non-vendor" css class to highlight it
            $traceEntries[$key]['nonVendorClass'] = $nonVendorFileClass;
            // Function call happens in a class outside the vendor folder
            // File may be non-vendor, but function call of the same trace entry is in a vendor class
            $traceEntries[$key]['nonVendorFunctionCallClass'] = $nonVendorFunctionCallClass;
            $traceEntries[$key]['classAndFunction'] = $classWithoutPath . $t['type'] . $t['function'];
            $traceEntries[$key]['fileName'] = $fileWithoutPath;
            $traceEntries[$key]['line'] = $t['line'];
        }

        return [
            'severityCssClassName' => $severityCssClassName,
            'exceptionClassName' => get_class($exception),
            'exceptionMessage' => $exceptionMessage,
            'pathToMainErrorFile' => $pathToMainErrorFile,
            'mainErrorFile' => $mainErrorFile,
            'errorLineNumber' => $lineNumber,
            'traceEntries' => $traceEntries,
        ];
    }

    /**
     * The stack trace contains the functions that are called during script execution with
     * function arguments that can be any type (objects, arrays, strings or null).
     * This function returns the argument as a string.
     *
     * @param mixed $argument
     *
     * @return string
     */
    private function getTraceArgumentAsString(mixed $argument): string
    {
        // If the variable is an object, return its class name.
        if (is_object($argument)) {
            return get_class($argument);
        }

        // If the variable is an array, iterate over its elements
        if (is_array($argument)) {
            $result = [];
            foreach ($argument as $key => $value) {
                // if it's an object, get its class name if it's an array represent it as 'Array'
                // otherwise, keep the original value.
                if (is_object($value)) {
                    $result[$key] = get_class($value);
                } elseif (is_array($value)) {
                    $result[$key] = 'Array';
                } else {
                    $result[$key] = $value;
                }
            }

            // Return the array converted to a string using var_export
            return var_export($result, true);
        }

        // If the variable is not an object or an array, convert it to a string using var_export.
        return var_export($argument, true);
    }

    /**
     * Convert the given argument to a string not longer than 15 chars
     * except if it's a file or a class name.
     *
     * @param mixed $argument the variable to be converted to a string
     *
     * @return string the string representation of the variable
     */
    private function getTraceArgumentAsTruncatedString(mixed $argument): string
    {
        if ($argument === null) {
            $formatted = 'NULL';
        } elseif (is_string($argument)) {
            // If string contains backslashes keep part after the last backslash, otherwise keep the first 15 chars
            if (str_contains($argument, '\\')) {
                $argument = $this->removeEverythingBeforeLastBackslash($argument);
            } elseif (strlen($argument) > 15) {
                $argument = substr($argument, 0, 15) . '...';
            }
            $formatted = '"' . $argument . '"';
        } elseif (is_object($argument)) {
            $formatted = get_class($argument);
            // Only keep the last part of class string
            if (strlen($formatted) > 15 && str_contains($formatted, '\\')) {
                $formatted = $this->removeEverythingBeforeLastBackslash($formatted);
            }
        } elseif (is_array($argument)) {
            // Convert each array element to string recursively
            $elements = array_map(function ($element) {
                return $this->getTraceArgumentAsTruncatedString($element);
            }, $argument);

            return '[' . implode(', ', $elements) . ']';
        } else {
            $formatted = (string)$argument;
        }

        return $formatted;
    }

    /**
     * If a string is 'App\Domain\Example\Class', this function returns 'Class'.
     *
     * @param string $string
     *
     * @return string
     */
    private function removeEverythingBeforeLastBackslash(string $string): string
    {
        return trim(substr($string, strrpos($string, '\\') + 1));
    }
}

Error details page

The template error/error-details.html.php shows the error message and stack trace in a responsive table.

The function html() is used to escape the values because we never know.

Template

File: templates/error/error-details.html.php

<?php
/**
 * @var string $basePath
 * @var string $severityCssClassName 'error' fatal errors or 'warning' for notices and warnings
 * @var int|null $statusCode http status code e.g. 404
 * @var string|null $reasonPhrase http reason phrase e.g. 'Not Found'
 * @var string|null $exceptionClassName e.g. 'HttpNotFoundException'
 * @var string|null $exceptionMessage e.g. 'Page not found.'
 * @var string|null $pathToMainErrorFile e.g. 'src\Application\Action\'
 * @var string|null $mainErrorFile e.g. 'UserAction.php'
 * @var int|null $errorLineNumber e.g. 123
 * @var array $traceEntries contains keys 'args' (function arguments),
 * 'nonVendorClass' (empty or 'non-vendor' to indicate that string should be highlighted),
 * 'nonVendorFunctionCallClass' (empty or 'non-vendor' to indicate that string should be highlighted),
 * 'classAndFunction' (class and function that was called in stack trace entry),
 * 'fileName' (name of the file in the stack trace entry), 'line' (line number)
 */

// Remove layout if there was a default
$this->setLayout('');
?>

<!DOCTYPE html>
<html lang="en">
<head>
    <base href="<?= $basePath ?>/"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="assets/error/error-details.css">
    <title>Error</title>
</head>

<body class="<?= html($severityCssClassName) ?>">
<div id="title-div" class="<?= html($severityCssClassName) ?>">
    <p><span><?= html($statusCode) ?> | <?= html($reasonPhrase) ?></span>
        <span id="exception-name"><?= html($exceptionClassName) ?></span>
    </p>
    <h1><?= html($exceptionMessage) ?> in <span id="first-path-chunk"><?= html($pathToMainErrorFile) ?></span>
        <?= html($mainErrorFile) ?>
        on line <?= html($errorLineNumber) ?>.
    </h1>
</div>
<div id="trace-div" class="<?= html($severityCssClassName) ?>">
    <table>
        <tr class="non-vendor">
            <th id="num-th">#</th>
            <th>Function</th>
            <th>Location</th>
        </tr>
        <?php
        foreach ($traceEntries as $key => $entry) { ?>
            <tr>
                <td class="<?= html($entry['nonVendorClass']) ?>"><?= html($key) ?></td>
                <td class="function-td <?= html($entry['nonVendorFunctionCallClass']) ?>">
                    <?= html($entry['classAndFunction']) ?>(
                    <?php
                    foreach ($entry['args'] as $argument) { ?>
                        <span class="args-span" data-full-details="<?= html($argument['detailed']) ?>">
                            <?= html($argument['truncated']) ?></span>,
                        <?php
                    } ?>
                    )
                </td>
                <td class="stack-trace-file-name <?= html($entry['nonVendorClass']) ?>">
                    <?= html($entry['fileName']) ?>:<span class="lineSpan"><?= html($entry['line']) ?></span>
                </td>
            </tr>
            <?php
        }
        ?>
    </table>
</div>
<script src="assets/error/error-details.js"></script>
</body>
</html>

Stylesheet

File: public/assets/error/error-details.css

@font-face {
    font-family: Poppins;
    src: url(Poppins-Regular.ttf);
    font-weight: normal;
}

/* mobile first min-width sets base and content is adapted to computers. */
@media (min-width: 100px) {

    * {
        overflow-wrap: anywhere;
    }

    body {
        margin: 0;
        background: #ffd9d0;
        font-family: Poppins, Geneva, AppleGothic, sans-serif;
    }

    body.warning {
        background: #ffead0;
    }

    body.error {
        background: #ffd9d0;
    }

    #title-div {
        padding: 5px 10%;
        color: black;
        background: tomato;
        border-radius: 0 35px;
        box-shadow: 0 0 17px tomato;
        box-sizing: border-box;
        margin: 30px 0;
        font-size: 0.8em;
    }

    #title-div h1 {
        margin-top: 4px;
    }

    #title-div.warning {
        background: orange;
        box-shadow: 0 0 17px orange;
    }

    #title-div.error {
        background: tomato;
        box-shadow: 0 0 17px tomato;
    }

    #first-path-chunk {
        font-size: 0.7em;
    }

    #trace-div {
        font-size: 0.8em;
        margin: auto auto 40px;
        min-width: 350px;
        padding: 20px;
        background: #ff9e88;
        border-radius: 0 35px;
        box-shadow: 0 0 10px #ff856e;
        width: 90%;
    }

    #trace-div.warning {
        background: #ffc588;
        box-shadow: 0 0 10px #ffad6e;
    }

    #trace-div.error {
        background: #ff9e88;
        box-shadow: 0 0 10px #ff856e;
    }

    #trace-div h2 {
        margin-top: 0;
        padding-top: 19px;
        text-align: center;
    }

    #trace-div table {
        border-collapse: collapse;
        width: 100%;
        overflow-x: auto;
    }

    #trace-div table td, #trace-div table th { /*border-top: 6px solid red;*/
        padding: 8px;
        text-align: left;
    }

    #trace-div table tr td:nth-child(3) {
        min-width: 100px;
    }

    #num-th {
        font-size: 2em;
        color: #a46856;
        margin-right: 50px;
    }

    .non-vendor {
        font-weight: bold;
        font-size: 1.2em;
    }

    .non-vendor .lineSpan {
        font-weight: bold;
        color: #b00000;
        font-size: 1.1em;
    }

    .is-vendor {
        font-weight: normal;
    }

    .args-span {
        color: #395186;
        cursor: pointer;
    }

    #exception-name {
        float: right
    }

    .function-td {
        font-size: 0.9em;
    }
}

@media (min-width: 641px) {
    #trace-div {
        width: 80%;
    }
}

@media (min-width: 810px) {
    #title-div {
        margin: 30px;
    }

    #trace-div table tr td:first-child, #trace-div table tr th:first-child {
        padding-left: 20px;
    }

    #title-div {
        font-size: 1em;
    }
}

@media (min-width: 1000px) {
    #trace-div {
        font-size: 1em;
    }
}

JavaScript

The following script makes that the stack trace file names are camel-wrapped and that the full details of the function arguments are displayed when clicking on them.

File: public/assets/error/error-details.js

window.onload = function () {
    // Camel-wrap all stack trace file names
    let elements = document.querySelectorAll('.stack-trace-file-name');
    elements.forEach(function (element) {
        camelWrap(element);
    });

    // Show full details when clicking on an argument
    // Select all spans with the class 'args-span'
    var spans = document.querySelectorAll('.args-span');

    // Add a click event listener to each span
    spans.forEach(function (span) {
        let spanExpanded = false;
        let formatted;
        span.addEventListener('click', function () {
            // Get the full details from the data attribute
            let fullDetails = this.getAttribute('data-full-details');
            // Display the full details and store the formatted text
            if (!spanExpanded) {
                formatted = this.innerText;
                span.innerText = fullDetails;
            } else {
                span.innerText = formatted;
            }
            spanExpanded = !spanExpanded;
        });
    });
}

/**
 * This function is used to apply the camelWrapUnicode function to a given DOM node
 * and then replace all zero-width spaces in the node's innerHTML with <wbr> elements.
 * The <wbr> element represents a word break opportunity—a position within text where
 * the browser may optionally break a line, though its line-breaking rules would not
 * otherwise create a break at that location.
 *
 * @param {Node} node - The DOM node to which the camelWrapUnicode function should
 * be applied and in whose innerHTML the zero-width spaces should be replaced
 * with <wbr> elements.
 */
function camelWrap(node) {
    camelWrapUnicode(node);
    node.innerHTML = node.innerHTML.replace(/\u200B/g, "<wbr>");
}

/**
 * This function is used to insert a zero-width space before each uppercase letter in
 * a camelCase string.
 * It does this by recursively traversing the DOM tree starting from the given node.
 * For each text node it finds, it replaces the node's value with a new string where
 * a zero-width space has been inserted before each uppercase letter.
 *
 * Source: http://heap.ch/blog/2016/01/19/camelwrap/
 * @param {Node} node - The node from where to start the DOM traversal.
 */
function camelWrapUnicode(node) {
    // Start from the first child of the given node and continue to the next sibling until there are no more siblings.
    for (node = node.firstChild; node; node = node.nextSibling) {
        // If the current node is a text node, replace its value.
        if (node.nodeType === Node.TEXT_NODE) {
            // Replace the node's value with a new string where a zero-width space has been inserted before each uppercase letter.
            // This is done by first matching any word character or colon that is repeated 18 or more times, and for each match,
            // a new string is returned where a zero-width space has been inserted before each uppercase letter.
            // The same is done by matching any dot character, but without the repetition requirement.
            node.nodeValue = node.nodeValue.replace(/[\w:]{18,}/g, function (str) {
                return str.replace(/([a-z])([A-Z])/g, "$1\u200B$2");
            });
        } else {
            // If the current node is not a text node, continue the traversal from this node.
            camelWrapUnicode(node);
        }
    }
}

Generic error page

The error page for a production environment displays the status code, the reason phrase, an error message depending on the status code and optionally a server message.
There is also a button to report the issue with a partially pre-written email and another one to go back home.

The navigation bar stays accessible for the user to navigate.

error-page

Template

The default layout is loaded to show the navigation bar, footer and potentially other elements.

File: templates/error/error-page.html.php

<?php

/**
 * @var \Slim\Interfaces\RouteParserInterface $route
 * @var array $errorMessage containing (int) statusCode; (string) reasonPhrase; (string) exceptionMessage
 * @var string|null $statusCode e.g. 403
 * @var string|null $reasonPhrase e.g. Forbidden
 * @var string|null $exceptionMessage e.g. You are not allowed to access this page.
 * @var \Slim\Views\PhpRenderer $this
 * @var array $config public config values
 */
$this->setLayout('layout.html.php');
?>
<?php
// Define assets that should be included
$this->addAttribute('css', ['assets/error/prod-error-page.css']);
$this->addAttribute('js', ['assets/error/prod-error-page.js']);
?>

<section id="error-inner-section">
    <h1 id="error-status-code"><?= html($statusCode) ?></h1>

    <section id="error-description-section">
        <?php
        switch ($statusCode) {
            case 404:
                $title = 'Page not found';
                $message = __("Looks like you've ventured into uncharted territory. Please report the issue!");
                break;
            case 403:
                $title = 'Access forbidden';
                $message = __(
                    'You are not allowed to access this page. Please report the issue if you think this is 
                an error.'
                );
                break;
            case 400:
                $title = 'The request is invalid';
                $message = __('There is something wrong with the request syntax. Please report the issue.');
                break;
            case 422:
                $title = 'Validation failed.';
                $message = __(
                    'The server could not interpret the data it received. Please try again with valid data and
                report the issue if it persists.'
                );
                break;
            case 500:
                $title = 'Internal Server Error.';
                $message = __(
                    'It\'s not your fault! The server has an internal error. <br> Please try again and 
                    report the issue if the problem persists.'
                );
                break;
            default:
                $title = 'An error occurred.';
                $message = __(
                    'While it\'s unfortunate that an error exists, the silver lining is that it can be rectified! 
                    <br>Please try again and then contact us.'
                );
                break;
        }
        $emailSubject = strip_tags(str_replace('"', '', $exceptionMessage))
            ?? $statusCode . ' ' . $title;
        $emailBody = __('This is what I did before the error happened:');
        ?>
        <h2 id="error-reason-phrase">OOPS! <?= html($title) ?></h2>
        <p id="error-message"><?=
            /* Not escape with html() because $message is safe as it is created above and has html tags */
            $message ?></p>
        <?= $exceptionMessage !== null ?
            '<p id="server-message">Server message: ' . html($exceptionMessage) . '</p>' : '' ?>

    </section>
    <section id="error-btn-section">
        <a href="<?= $route?->urlFor('home-page') ?>" class="btn"><?= __('Go back home') ?></a>
        <a href="mailto:<?= ($config['email']['main_contact_address'] ?? 'contact@samuel-gfeller.ch')
        . '?subject=' . html($emailSubject) . '&body=' . html($emailBody) ?>" target="_blank" class="btn">
            <?= __('Report the issue') ?></a>
    </section>
</section>

Stylesheet

@font-face {
    font-family: Poppins;
    src: url(Poppins-Bold.ttf);
    font-weight: 700;
}

@font-face {
    font-family: Poppins;
    src: url(Poppins-Regular.ttf);
}

:root {
    --error-body-gradient-color-1: #49d2ff;
    --error-body-gradient-color-2: #ea9bc2;

    --error-inner-section-background: rgba(255, 255, 255, 0.4);
    --error-reason-phrase-color: #535353;

    --error-status-code-gradient-color-1: #00c1ff;
    --error-status-code-gradient-color-2: #ff6bb4;

}

[data-theme="dark"] {
    --error-body-gradient-color-1: rgb(102, 93, 182);
    --error-body-gradient-color-2: rgb(64, 148, 157);
    --error-inner-section-background: rgba(0, 0, 0, 0.4);
    --error-reason-phrase-color: #a9a9a9;
}


@media (min-width: 100px) {
    body {
        background: linear-gradient(to bottom right, var(--error-body-gradient-color-1) 0%, var(--error-body-gradient-color-2) 100%);
    }

    main {
        display: flex;
        align-items: center;
        justify-content: center;
        /*background: lightblue;*/
        margin-left: 0;
        margin-top: 0;
        border-radius: 0 0 0 0;
        background: transparent;
    }

    footer {
        background: var(--error-inner-section-background);
        margin-top: 0;
        /*border-radius: 0;*/
    }

    #error-inner-section {
        width: fit-content;
        max-width: 92%;
        height: fit-content;
        padding: 40px 30px;
        /*border: 1px solid #ccc;*/
        /*margin-left: 50px;*/
        text-align: center;
        border-radius: 30px;
        background: var(--error-inner-section-background);
        /*backdrop-filter: blur(50px);*/
        box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
    }

    /* #error-status-code */
    #error-inner-section h1 {
        font-size: clamp(100px, 18vw, 200px);
        font-family: Poppins, Helvetica, sans-serif;
        line-height: 1em;
        margin-bottom: 0;
        margin-top: 0px;
        position: relative;
        background: linear-gradient(to bottom right, var(--error-status-code-gradient-color-1) 0%,
        var(--error-status-code-gradient-color-2) 100%);
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
    }

    #error-inner-section h2 {
        text-transform: uppercase;
        font-family: Poppins, Helvetica, sans-serif;
        color: var(--error-reason-phrase-color);
        /*font-family: SF-Pro Display, Helvetica, sans-serif;*/
    }

    #error-inner-section p {
        font-family: Poppins, Helvetica, sans-serif;
        font-weight: 400;
        font-size: 1.2em;
    }

    p#server-message {
        font-size: 1em;
    }

    #error-btn-section {
        margin-top: 30px;
        display: flex;
        flex-wrap: wrap;
        justify-content: space-evenly;
    }

    #error-btn-section .btn {
        background: rgba(255, 255, 255, 0.4);
        margin: 10px;
    }
}

@media (min-width: 641px) {

    #error-inner-section {
        padding: 40px 50px;
        max-width: 80%;
    }
}

@media (min-width: 961px) {
    #error-inner-section {
        padding: 40px 100px;
    }
}

JavaScript

In an effort to make the error page a little less boring, the linear gradient direction of the status code follows the cursor.

File: public/assets/error/prod-error-page.js

const statusCode = document.getElementById('error-status-code');


// Make the linear gradient direction of the status code follow the cursor
document.documentElement.addEventListener("mousemove", function (event) {
    // Retrieve the bounding rectangle of the "statusCode" element
    const {left, top, width, height} = statusCode.getBoundingClientRect();

    // Calculate the center coordinates of the "statusCode" element
    const centerX = left + width / 2;
    const centerY = top + height / 2;

    // Calculate the angle (in radians) between the cursor position and the center of the element
    const radians = Math.atan2(event.clientY - centerY, event.clientX - centerX);

    // Convert the angle from radians to degrees
    const degrees = radians * (180 / Math.PI);

    // Add 90 degrees to shift the range from [-180, 180] to [0, 360] degrees
    const gradientDirection = degrees + 90;

    // Apply the linear gradient background to the "statusCode" element
    const style = getComputedStyle(document.body);
    const color1 = style.getPropertyValue('--error-status-code-gradient-color-1');
    const color2 = style.getPropertyValue('--error-status-code-gradient-color-2');
    setTimeout(() => {
            statusCode.style.backgroundImage = `linear-gradient(${gradientDirection}deg, ${color1} 0%, ${color2} 100%)`;
        }, 300
    )
});
Clone this wiki locally