Modern error handler with beautiful debug pages, RFC 9457 JSON errors, customizable renderers, solution providers, and PSR-15 middleware support. One-line setup. Standalone.
- 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
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.
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.
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
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.
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.
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).
| 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.
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);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.
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);All renderers implement RendererInterface — a single method:
interface RendererInterface
{
public function render(ErrorContext $context): string;
}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');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');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'."
}
]
}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.
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));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
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.
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'));interface SolutionProviderInterface
{
public function canSolve(\Throwable $exception): bool;
/** @return list<Solution> */
public function getSolutions(\Throwable $exception): array;
}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'),
],
);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.
interface ContextProviderInterface
{
public function getLabel(): string;
/** @return array<string, mixed> */
public function collect(\Throwable $exception, ?ServerRequestInterface $request): array;
}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.
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
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>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>Each line in a code snippet:
$line->lineNumber; // 42
$line->code; // ' throw new \RuntimeException("User not found");'
$line->isHighlighted; // true (the error line)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.
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']);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.
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(); // 42PSR-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);| 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 |
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
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
final class ErrorHandlerMiddleware implements MiddlewareInterface
__construct(
ExceptionHandler $handler,
ResponseFactoryInterface $responseFactory,
StreamFactoryInterface $streamFactory,
)
process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
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
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
final readonly class Solution
string $title
string $description
list<SolutionLink> $links
final readonly class SolutionLink
string $label
string $url
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>
final class FatalErrorException extends ErrorException
static fromLastError(array{type:int, message:string, file:string, line:int} $error): self
MIT