Skip to content

phpdot/error-handler

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

phpdot/error-handler

Modern error handler with beautiful debug pages, RFC 9457 JSON errors, customizable renderers, solution providers, and PSR-15 middleware support. One-line setup. Standalone.


Table of Contents


Install

composer require phpdot/error-handler
Requirement Version
PHP >= 8.3
psr/http-message ^2.0
psr/http-server-middleware ^1.0
psr/log ^3.0

Zero phpdot dependencies. Works with any PSR-7 implementation.


Quick Start

use PHPdot\ErrorHandler\ErrorHandler;

// One line. Works immediately.
ErrorHandler::register('development');

That's it. Uncaught exceptions get a beautiful debug page. PHP warnings and notices become exceptions. Fatal errors are caught on shutdown.

Switch to production:

ErrorHandler::register('production');

Clean error pages. No internals exposed. Same handler.


Architecture

Dispatch Pipeline

Exception caught
    ↓
ExceptionHandler::handle($exception, $request?)
    │
    ├── 1. Collect
    │   ├── Build StackTrace (frames with code snippets)
    │   ├── Collect request info (if PSR-7 available)
    │   ├── Run ContextProviders (queries, routes, custom tabs)
    │   ├── Run SolutionProviders (suggested fixes)
    │   ├── Filter environment (mask sensitive keys)
    │   └── Package into ErrorContext DTO
    │
    ├── 2. Log (PSR-3)
    │   └── logger->log(level, message, [exception, status_code])
    │   └── 5xx → error, 4xx → warning, other → notice
    │
    ├── 3. Render
    │   ├── Accept: application/json → JsonRenderer (RFC 9457)
    │   ├── Development → HtmlDevRenderer (debug page)
    │   ├── Production → HtmlProdRenderer (clean page)
    │   └── CLI → PlainTextRenderer
    │
    └── 4. Output
        ├── Standalone: echo + http_response_code()
        └── Middleware: PSR-7 Response

Package Structure

src/
├── ErrorHandler.php                    # Static register() — one-line setup
├── ExceptionHandler.php                # Core: collect, log, render
├── Middleware/
│   └── ErrorHandlerMiddleware.php      # PSR-15 middleware
├── Context/
│   ├── ErrorContext.php                # DTO — all data for rendering
│   ├── StackTrace.php                 # Parsed trace with code snippets
│   ├── Frame.php                      # Single stack frame
│   ├── CodeLine.php                   # Single line of source code
│   └── ContextTab.php                 # Named tab of extra debug data
├── Contract/
│   ├── RendererInterface.php           # ErrorContext → string
│   ├── ContextProviderInterface.php    # Extra debug tabs
│   └── SolutionProviderInterface.php   # Suggested fixes
├── Renderer/
│   ├── HtmlDevRenderer.php             # Beautiful dev debug page
│   ├── HtmlProdRenderer.php            # Clean production page
│   ├── JsonRenderer.php                # RFC 9457 Problem Details
│   └── PlainTextRenderer.php           # CLI output
├── Solution/
│   ├── Solution.php                    # DTO — title, description, links
│   └── SolutionLink.php               # DTO — label, url
└── Exception/
    └── FatalErrorException.php         # Wraps fatal errors

templates/
├── dev.html.php                        # Default dev page (pure HTML/CSS)
└── prod.html.php                       # Default production page

18 source files + 2 templates. 1,079 lines.


ErrorHandler (Global Registration)

One-Line Setup

use PHPdot\ErrorHandler\ErrorHandler;

ErrorHandler::register('development');

Registers set_exception_handler, set_error_handler, and register_shutdown_function. Automatically selects PlainTextRenderer for CLI, HTML renderers for web.

Full Configuration

ErrorHandler::register('development')
    ->setLogger($psrLogger)
    ->setDevRenderer(new HtmlDevRenderer('/path/to/custom-dev.html.php'))
    ->setProdRenderer(new HtmlProdRenderer('/path/to/custom-prod.html.php'))
    ->setJsonRenderer(new JsonRenderer())
    ->addContextProvider(new QueryLogProvider($queryLogger))
    ->addContextProvider(new RouteContextProvider($router))
    ->addSolutionProvider(new ClassNotFoundSolution())
    ->addSolutionProvider(new ViewNotFoundSolution())
    ->setSensitiveKeys(['DB_PASSWORD', 'APP_KEY', 'AWS_SECRET']);

All methods are chainable (return self).

What Gets Registered

Handler Purpose
set_exception_handler Catches uncaught exceptions, renders, and outputs
set_error_handler Converts PHP warnings/notices/deprecations to ErrorException
register_shutdown_function Catches fatal errors (E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR)

The error handler respects the @ suppression operator — suppressed errors are not converted.


ExceptionHandler (The Core)

Direct Usage

Use ExceptionHandler directly when you don't want global registration (e.g. in PSR-15 middleware):

use PHPdot\ErrorHandler\ExceptionHandler;
use PHPdot\ErrorHandler\Renderer\HtmlDevRenderer;
use PHPdot\ErrorHandler\Renderer\HtmlProdRenderer;
use PHPdot\ErrorHandler\Renderer\JsonRenderer;

$handler = new ExceptionHandler(
    environment: 'development',
    devRenderer: new HtmlDevRenderer(),
    prodRenderer: new HtmlProdRenderer(),
    jsonRenderer: new JsonRenderer(),
    logger: $psrLogger,
);

// Handle an exception → returns rendered string (HTML or JSON)
$output = $handler->handle($exception);

// With PSR-7 request (enables JSON detection and request tab)
$output = $handler->handle($exception, $request);

Status Code Mapping

The handler determines HTTP status codes automatically:

$handler->getStatusCode($exception); // int
Exception Type Status Code
Has getStatusCode() method Uses returned value
InvalidArgumentException 400
DomainException 422
RuntimeException 500
LogicException 500
TypeError 500
Everything else 500

Framework HTTP exceptions (e.g. NotFoundHttpException with getStatusCode() returning 404) are detected via method_exists — no dependency on any HTTP exception package.

JSON Detection

If the request has Accept: application/json or Accept: application/problem+json, the JSON renderer is used automatically:

// Returns HTML (no request or HTML accept)
$handler->handle($exception);

// Returns RFC 9457 JSON
$handler->handle($exception, $jsonRequest);

Renderers

All renderers implement RendererInterface — a single method:

interface RendererInterface
{
    public function render(ErrorContext $context): string;
}

HtmlDevRenderer

Beautiful debug page with code snippets, stack trace, tabs, solutions. Uses a PHP template.

use PHPdot\ErrorHandler\Renderer\HtmlDevRenderer;

// Default template
$renderer = new HtmlDevRenderer();

// Custom template
$renderer = new HtmlDevRenderer('/path/to/my-dev-page.html.php');

HtmlProdRenderer

Clean error page. No internals exposed. User-friendly messages.

use PHPdot\ErrorHandler\Renderer\HtmlProdRenderer;

$renderer = new HtmlProdRenderer();
$renderer = new HtmlProdRenderer('/path/to/my-error-page.html.php');

JsonRenderer (RFC 9457)

Returns RFC 9457 Problem Details JSON.

Production output (safe):

{
    "type": "about:blank",
    "title": "Not Found",
    "status": 404,
    "detail": "The requested resource was not found."
}

Development output (full debug info):

{
    "type": "about:blank",
    "title": "Internal Server Error",
    "status": 500,
    "detail": "Class 'App\\Service\\UserService' not found",
    "exception": {
        "class": "Error",
        "message": "Class 'App\\Service\\UserService' not found",
        "file": "/app/src/Controller/UserController.php",
        "line": 15,
        "trace": [
            {"file": "/app/src/Controller/UserController.php", "line": 15, "class": null, "function": null},
            {"file": "/app/vendor/framework/router.php", "line": 42, "class": "Router", "function": "dispatch"}
        ]
    },
    "solutions": [
        {
            "title": "Class 'UserService' not found",
            "description": "Check that the class exists and run 'composer dump-autoload'."
        }
    ]
}

PlainTextRenderer

CLI-friendly output. Used automatically when PHP_SAPI === 'cli'.

[RuntimeException] Connection refused in /app/src/Database.php:42

Stack trace:
#0 /app/src/Database.php:42 Database::connect()
#1 /app/src/App.php:15 App::boot()
#2 /app/public/index.php:8 {main}()

Suggested solutions:
  - Database not running: Start your database server and verify the connection settings.

Custom Renderers

Build your own renderer — implement RendererInterface:

final class TwigRenderer implements RendererInterface
{
    public function __construct(
        private readonly \Twig\Environment $twig,
    ) {}

    public function render(ErrorContext $context): string
    {
        return $this->twig->render('error.html.twig', [
            'exception' => $context->exception,
            'statusCode' => $context->statusCode,
            'stackTrace' => $context->stackTrace,
            'solutions' => $context->solutions,
        ]);
    }
}

ErrorHandler::register('development')
    ->setDevRenderer(new TwigRenderer($twig));

Dev Debug Page

Dev Page Features

The default dev template (templates/dev.html.php) is pure HTML/CSS with minimal vanilla JS (~15 lines for tab switching and frame collapsing). No build step. No JS framework.

  • Exception header — status code badge, class name, message, file:line
  • Solutions panel — suggested fixes with documentation links (green cards)
  • Stack trace — collapsible frames with code snippets
    • Application frames highlighted, vendor frames dimmed
    • Error line highlighted in red
    • Line numbers in gutter
  • Request tab — method, URI, headers (when PSR-7 request provided)
  • Environment tab — server variables with sensitive keys masked
  • Context tabs — one tab per registered ContextProvider
  • Dark/light mode — CSS prefers-color-scheme (automatic)
  • Responsive — works on mobile

Customizing the Template

Replace the entire dev page — the template receives $errorContext (ErrorContext DTO):

ErrorHandler::register('development')
    ->setDevRenderer(new HtmlDevRenderer('/path/to/my-dev-page.html.php'));

Your template has access to all data:

<!-- my-dev-page.html.php -->
<?php /** @var \PHPdot\ErrorHandler\Context\ErrorContext $errorContext */ ?>

<h1><?= htmlspecialchars($errorContext->exception::class) ?></h1>
<p><?= htmlspecialchars($errorContext->exception->getMessage()) ?></p>
<p>Status: <?= $errorContext->statusCode ?></p>

<?php foreach ($errorContext->stackTrace->frames as $frame): ?>
    <div><?= htmlspecialchars($frame->file) ?>:<?= $frame->line ?></div>
    <?php foreach ($frame->codeSnippet as $line): ?>
        <code class="<?= $line->isHighlighted ? 'error-line' : '' ?>">
            <?= $line->lineNumber ?>: <?= htmlspecialchars($line->code) ?>
        </code>
    <?php endforeach ?>
<?php endforeach ?>

<?php foreach ($errorContext->solutions as $solution): ?>
    <div class="solution">
        <h3><?= htmlspecialchars($solution->title) ?></h3>
        <p><?= htmlspecialchars($solution->description) ?></p>
    </div>
<?php endforeach ?>

You replace the presentation, not the data pipeline.


Production Error Page

The default production template shows a user-friendly message with no internals:

  • Large status code number (dimmed)
  • Human-readable title ("Page Not Found", "Server Error")
  • Friendly message ("Something went wrong on our end.")
  • "Go Home" link
  • Dark/light mode

Status-specific messages:

Code Title Message
400 Bad Request The request could not be processed.
401 Unauthorized You need to sign in to access this page.
403 Forbidden You don't have permission to access this page.
404 Page Not Found The page you're looking for doesn't exist.
500 Server Error Something went wrong on our end.
503 Service Unavailable We're temporarily down for maintenance.

Replace with your own:

ErrorHandler::register('production')
    ->setProdRenderer(new HtmlProdRenderer('/path/to/my-error-page.html.php'));

Solution Providers

SolutionProviderInterface

interface SolutionProviderInterface
{
    public function canSolve(\Throwable $exception): bool;

    /** @return list<Solution> */
    public function getSolutions(\Throwable $exception): array;
}

Solution and SolutionLink DTOs

use PHPdot\ErrorHandler\Solution\Solution;
use PHPdot\ErrorHandler\Solution\SolutionLink;

$solution = new Solution(
    title: "Class 'UserService' not found",
    description: "Check that the class exists and run 'composer dump-autoload'.",
    links: [
        new SolutionLink('Composer Autoload', 'https://getcomposer.org/doc/01-basic-usage.md#autoloading'),
    ],
);

Building a Solution Provider

final class ClassNotFoundSolution implements SolutionProviderInterface
{
    public function canSolve(\Throwable $exception): bool
    {
        return $exception instanceof \Error
            && str_contains($exception->getMessage(), 'not found');
    }

    public function getSolutions(\Throwable $exception): array
    {
        return [
            new Solution(
                title: 'Class not found',
                description: "Check the namespace, verify the file exists, and run 'composer dump-autoload'.",
                links: [
                    new SolutionLink('Composer Autoload', 'https://getcomposer.org/doc/01-basic-usage.md#autoloading'),
                ],
            ),
        ];
    }
}

// Register
ErrorHandler::register('development')
    ->addSolutionProvider(new ClassNotFoundSolution());

Solutions appear in the debug page (green cards) and in JSON output (solutions array). If a solution provider throws, the error handler catches it silently — solution collection never crashes the handler.


Context Providers

ContextProviderInterface

interface ContextProviderInterface
{
    public function getLabel(): string;

    /** @return array<string, mixed> */
    public function collect(\Throwable $exception, ?ServerRequestInterface $request): array;
}

Building a Context Provider

Each provider adds a tab to the debug page:

// Database query log tab
final class QueryLogProvider implements ContextProviderInterface
{
    public function __construct(private readonly QueryLogger $logger) {}

    public function getLabel(): string { return 'Queries'; }

    public function collect(\Throwable $exception, ?ServerRequestInterface $request): array
    {
        return [
            'total' => $this->logger->count(),
            'slow' => count($this->logger->getSlow()),
            'queries' => array_map(fn($q) => [
                'operation' => $q->operation,
                'collection' => $q->collection,
                'duration' => $q->durationMs . 'ms',
            ], $this->logger->getAll()),
        ];
    }
}

// Route info tab
final class RouteContextProvider implements ContextProviderInterface
{
    public function getLabel(): string { return 'Route'; }

    public function collect(\Throwable $exception, ?ServerRequestInterface $request): array
    {
        return [
            'method' => $request?->getMethod() ?? 'N/A',
            'path' => $request?->getUri()->getPath() ?? 'N/A',
            'route' => $request?->getAttribute('route') ?? 'none',
        ];
    }
}

// Register
ErrorHandler::register('development')
    ->addContextProvider(new QueryLogProvider($queryLogger))
    ->addContextProvider(new RouteContextProvider());

If a context provider throws, it's caught silently — context collection never crashes the handler.


PSR-15 Middleware

Wrap your entire application pipeline:

use PHPdot\ErrorHandler\Middleware\ErrorHandlerMiddleware;

$middleware = new ErrorHandlerMiddleware(
    handler: $exceptionHandler,
    responseFactory: $responseFactory,     // PSR-17
    streamFactory: $streamFactory,         // PSR-17
);

// In your middleware stack (outermost layer)
$app->pipe($middleware);

The middleware catches any \Throwable, renders it via ExceptionHandler, and returns a PSR-7 Response with:

  • Correct HTTP status code
  • Content-Type: application/problem+json for JSON requests
  • Content-Type: text/html for HTML requests

Stack Trace and Frames

StackTrace

Built automatically from exceptions. Extracts code snippets from source files.

use PHPdot\ErrorHandler\Context\StackTrace;

$trace = StackTrace::fromException($exception, contextLines: 9);
// $trace->frames — list<Frame>

Frame

Each frame contains file, line, call info, code snippet, and an application flag:

$frame->file;           // '/app/src/Service/UserService.php'
$frame->line;           // 42
$frame->class;          // 'App\Service\UserService' or null
$frame->function;       // 'findUser' or null
$frame->isApplication;  // true (false for vendor/ paths)
$frame->codeSnippet;    // list<CodeLine>

CodeLine

Each line in a code snippet:

$line->lineNumber;    // 42
$line->code;          // '    throw new \RuntimeException("User not found");'
$line->isHighlighted; // true (the error line)

ErrorContext (The Data Contract)

ErrorContext is the bridge between data collection and rendering. Every renderer receives the same structured data:

final readonly class ErrorContext
{
    public \Throwable $exception;
    public StackTrace $stackTrace;
    public int $statusCode;
    public ?ServerRequestInterface $request;
    public array $environment;      // filtered server vars
    public array $context;          // list<ContextTab>
    public array $solutions;        // list<Solution>
    public bool $isDevelopment;
}

This is why templates are replaceable — you replace the presentation, not the data pipeline.


Environment Filtering

Server variables are shown in the debug page with sensitive keys masked:

DB_PASSWORD    ********
APP_KEY        ********
AWS_SECRET     ********
SERVER_NAME    localhost
HTTP_HOST      example.com

Default sensitive patterns (case-insensitive substring match): PASSWORD, SECRET, KEY, TOKEN, CREDENTIAL, DB_PASSWORD, APP_KEY, AWS_SECRET, API_KEY, PRIVATE_KEY, AUTH_TOKEN.

Customize:

ErrorHandler::register('development')
    ->setSensitiveKeys(['PASSWORD', 'SECRET', 'KEY', 'STRIPE_SK', 'MY_CUSTOM_TOKEN']);

PHP Error Conversion

All PHP warnings, notices, and deprecations are converted to ErrorException:

// This now throws ErrorException instead of emitting a warning
$result = array_pop($notAnArray);

The @ suppression operator is respected — suppressed errors are not converted.


Fatal Error Catching

E_ERROR, E_PARSE, E_CORE_ERROR, and E_COMPILE_ERROR are caught on shutdown via register_shutdown_function and wrapped in FatalErrorException (extends ErrorException):

use PHPdot\ErrorHandler\Exception\FatalErrorException;

// Create from error_get_last() array
$exception = FatalErrorException::fromLastError([
    'type' => E_ERROR,
    'message' => 'Allowed memory size exhausted',
    'file' => '/app/src/Service.php',
    'line' => 42,
]);

$exception->getMessage();  // 'Allowed memory size exhausted'
$exception->getSeverity(); // E_ERROR
$exception->getFile();     // '/app/src/Service.php'
$exception->getLine();     // 42

Exception Handling

PSR-3 logging with status-code-based log levels:

Status Code PSR-3 Level
500+ error
400-499 warning
< 400 notice
ErrorHandler::register('development')
    ->setLogger($psrLogger);

Comparison

Feature PHPdot Symfony Whoops Ignition
One-line setup register() Debug::enable() 3 lines make()->register()
Code snippets Yes Yes Yes Yes
Collapsible frames Yes No No Yes
App vs vendor frames Yes No No Yes
Dark/light mode Yes No No Yes
Solutions panel Yes No No Yes
Custom context tabs Yes No addDataTable() Laravel-only
Replace dev template Yes No No No
Replace prod template Yes setTemplate() No Yes
RFC 9457 JSON Yes No JSON handler No
PSR-15 middleware Yes No No No
PSR-7 request Yes No No No
No JS framework Yes Yes Yes No (React)
Standalone Yes Yes Yes Partially

API Reference

ErrorHandler API

final class ErrorHandler

static register(string $environment = 'production'): self
setLogger(LoggerInterface $logger): self
setDevRenderer(RendererInterface $renderer): self
setProdRenderer(RendererInterface $renderer): self
setJsonRenderer(RendererInterface $renderer): self
addContextProvider(ContextProviderInterface $provider): self
addSolutionProvider(SolutionProviderInterface $provider): self
setSensitiveKeys(list<string> $keys): self
getExceptionHandler(): ExceptionHandler

ExceptionHandler API

final class ExceptionHandler

__construct(
    string $environment,
    RendererInterface $devRenderer,
    RendererInterface $prodRenderer,
    RendererInterface $jsonRenderer,
    ?LoggerInterface $logger = null,
)

handle(Throwable $exception, ?ServerRequestInterface $request = null): string
getStatusCode(Throwable $exception): int
setLogger(LoggerInterface $logger): void
setDevRenderer(RendererInterface $renderer): void
setProdRenderer(RendererInterface $renderer): void
setJsonRenderer(RendererInterface $renderer): void
addContextProvider(ContextProviderInterface $provider): void
addSolutionProvider(SolutionProviderInterface $provider): void
setSensitiveKeys(list<string> $keys): void
getEnvironment(): string

ErrorHandlerMiddleware API

final class ErrorHandlerMiddleware implements MiddlewareInterface

__construct(
    ExceptionHandler $handler,
    ResponseFactoryInterface $responseFactory,
    StreamFactoryInterface $streamFactory,
)

process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface

Renderers API

final class HtmlDevRenderer implements RendererInterface
    __construct(string $templatePath = 'templates/dev.html.php')
    render(ErrorContext $context): string

final class HtmlProdRenderer implements RendererInterface
    __construct(string $templatePath = 'templates/prod.html.php')
    render(ErrorContext $context): string

final class JsonRenderer implements RendererInterface
    render(ErrorContext $context): string

final class PlainTextRenderer implements RendererInterface
    render(ErrorContext $context): string

Context DTOs API

final readonly class ErrorContext
    Throwable           $exception
    StackTrace          $stackTrace
    int                 $statusCode
    ?ServerRequestInterface $request
    array<string,string> $environment
    list<ContextTab>    $context
    list<Solution>      $solutions
    bool                $isDevelopment

final readonly class StackTrace
    list<Frame>         $frames
    static fromException(Throwable $exception, int $contextLines = 9): self

final readonly class Frame
    string   $file
    int      $line
    ?string  $class
    ?string  $function
    list<CodeLine> $codeSnippet
    bool     $isApplication

final readonly class CodeLine
    int      $lineNumber
    string   $code
    bool     $isHighlighted

final readonly class ContextTab
    string   $label
    array<string,mixed> $data

Solution DTOs API

final readonly class Solution
    string           $title
    string           $description
    list<SolutionLink> $links

final readonly class SolutionLink
    string  $label
    string  $url

Contracts API

interface RendererInterface
    render(ErrorContext $context): string

interface ContextProviderInterface
    getLabel(): string
    collect(Throwable $exception, ?ServerRequestInterface $request): array<string,mixed>

interface SolutionProviderInterface
    canSolve(Throwable $exception): bool
    getSolutions(Throwable $exception): list<Solution>

FatalErrorException API

final class FatalErrorException extends ErrorException
    static fromLastError(array{type:int, message:string, file:string, line:int} $error): self

License

MIT

About

Modern error handler with beautiful debug pages, RFC 9457 JSON errors, customizable renderers, solution providers, and PSR-15 middleware.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages