phpdot / error-handler
Modern error handler with beautiful debug pages, RFC 9457 JSON errors, customizable renderers, solution providers, and PSR-15 middleware support.
Requires
- php: >=8.3
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
README
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
- Quick Start
- Architecture
- ErrorHandler (Global Registration)
- ExceptionHandler (The Core)
- Renderers
- Dev Debug Page
- Production Error Page
- Solution Providers
- Context Providers
- PSR-15 Middleware
- Stack Trace and Frames
- ErrorContext (The Data Contract)
- Environment Filtering
- PHP Error Conversion
- Fatal Error Catching
- Exception Handling
- Comparison
- API Reference
- License
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+jsonfor JSON requestsContent-Type: text/htmlfor 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