yiisoft/error-handler
Yii error handler for PHP applications that converts errors and exceptions into PSR-7 responses. It supports debug and production modes, HTML and JSON output, logging, and PSR-15 middleware for consistent error handling.
yiisoft/error-handler is not an error-logging library and not a Laravel exception handler. It is a PSR-15 middleware that catches any Throwable escaping your middleware stack and converts it into a PSR-7 response, choosing an HTML / JSON / XML / plain-text body based on the request's Accept header. Think of it as App\Exceptions\Handler::render() reimplemented for any PSR-7/PSR-15 application.
composer require yiisoft/error-handler
# you must supply PSR-7/PSR-17 yourself; the package ships no HTTP implementation
composer require nyholm/psr7 psr/log
Look at these files first, in this order:
src/ErrorHandler.php — the PSR-15 middleware (in 0.x it was Middleware/ErrorHandlerMiddleware.php; both names appear in the wild).src/ThrowableResponseFactory.php — decides renderer + status code, builds the response.src/Renderer/*.php — HtmlRenderer, JsonRenderer, XmlRenderer, PlainTextRenderer.src/ThrowableRendererInterface.php — the extension point you will actually implement.config/ in the repo — the maintained reference DI wiring. Copy that, do not invent your own.Minimal wiring:
use Psr\Log\LoggerInterface;
use Yiisoft\ErrorHandler\ErrorHandler;
use Yiisoft\ErrorHandler\ThrowableResponseFactory;
use Yiisoft\ErrorHandler\Renderer\{HtmlRenderer, JsonRenderer, PlainTextRenderer};
$factory = new ThrowableResponseFactory(
renderers: [
'text/html' => new HtmlRenderer(),
'application/json' => new JsonRenderer(),
'text/plain' => new PlainTextRenderer(),
],
defaultRenderer: new PlainTextRenderer(),
);
$handler = new ErrorHandler($factory, $container->get(LoggerInterface::class), debug: (bool) $_ENV['APP_DEBUG']);
First use case to get value immediately: a JSON API. Replace Laravel's whoops/HTML error page with a stable application/json envelope, log every Throwable at error level, and return a generic message in production while keeping the full trace behind debug. In Laravel terms this collapses report(), render(), and content negotiation into one middleware. Add it as the outermost middleware of a Slim/Mezzio/Yii3/custom pipeline and you are done.
If you are staying inside Laravel, do not fight the framework — read this package as a design reference for App\Exceptions\Handler, and only install it when you are writing a package or service that must run outside Laravel.
Renderer-per-content-type. The handler does not decide the body; the factory does. Keep one renderer class per media type and register them keyed by exact content type. This is the single most portable idea to steal for Laravel APIs:
final class ProblemJsonRenderer implements ThrowableRendererInterface
{
public function render(Throwable $e): string
{
return json_encode(['type' => 'about:blank', 'title' => 'Internal Server Error'], JSON_THROW_ON_ERROR);
}
public function renderVerbose(Throwable $e): string
{
return json_encode([
'title' => $e->getMessage(),
'class' => $e::class,
'file' => $e->getFile() . ':' . $e->getLine(),
'trace' => explode("\n", $e->getTraceAsString()),
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
}
}
Register it under application/problem+json (and as the default for /api/* routes, see Gotchas).
Implement both render() and renderVerbose(). render() is the production body and must be safe to show a stranger. renderVerbose() is the debug body. Returning the trace from render() is the most common way people leak stack traces and absolute paths to production.
Status-code mapping via a response-factory decorator. Unlike Laravel, NotFoundHttpException does not automatically become 404 — everything is 500 unless you say otherwise. Wrap the factory:
final class MappingResponseFactory implements ThrowableResponseFactoryInterface
{
public function __construct(private ThrowableResponseFactoryInterface $inner) {}
public function createResponse(Throwable $e, ServerRequestInterface $request): ResponseInterface
{
$response = $this->inner->createResponse($e, $request);
$status = match (true) {
$e instanceof NotFoundException => 404,
$e instanceof ValidationException => 422,
$e instanceof AuthenticationException => 401,
$e instanceof DomainException => 400,
default => $response->getStatusCode(),
};
return $response->withStatus($status);
}
}
This is the direct analogue of Laravel's renderable() / $internalDontReport hooks, and it is where you also add $e instanceof ShouldntReport filtering before the logger sees it.
Environment-driven debug. Wire debug from APP_DEBUG, exactly as you would config('app.debug'). Debug output includes source excerpts; treat it as a local-development feature only.
Logging. Pass a PSR-3 logger and let the handler log every caught Throwable at error. If you also have a Monolog handler or Sentry integration that reports exceptions, guard against double-reporting (e.g. a ShouldntReport interface checked in a wrapper logger).
Middleware placement. Outermost, so it also catches router 404s and DI/container failures that occur after it. In Slim: $app->add(ErrorHandler::class) before routing; in Mezzio: $app->pipe(ErrorHandler::class) as the first pipe.
Testing pattern. Construct a request with an Accept header, pipe a middleware that throws, and assert status, Content-Type, and body:
$handler = new ErrorHandler($factory, new NullLogger(), debug: false);
$response = $handler->process(
new ServerRequest('GET', '/x')->withHeader('Accept', 'application/json'),
new class implements RequestHandlerInterface {
public function handle(ServerRequestInterface $r): ResponseInterface { throw new RuntimeException('boom'); }
}
);
self::assertSame(500, $response->getStatusCode());
self::assertSame('application/json', $response->getHeaderLine('Content-Type'));
Laravel bridge (only if you must). If you genuinely need PSR-15 error semantics inside Laravel, bind the handler in a service provider and wrap it with symfony/psr-http-message-bridge + nyholm/psr7 inside a Laravel middleware. In practice: convert Illuminate\Http\Request to PSR-7, call process(), convert back. This is viable for shared package code, but it is more moving parts than Laravel's native handler — do it for consistency across services, not for features.
Prettier traces. Pair with yiisoft/error-tracer if you want formatted, collapsed traces in the HTML renderer rather than raw getTraceAsString().
Handler class. Laravel's pipeline is not PSR-15; there is no drop-in swap. Decide up front whether you are bridging or just borrowing ideas.set_exception_handler + register_shutdown_function) and keep display_errors=Off so PHP doesn't emit a second, conflicting body after yours.Accept: */* (curl, many SDKs) can yield HTML. Content negotiation defaults to the configured default renderer. For API route groups, force the JSON renderer instead of relying on the client's header.render() deliberately. A missing or trace-leaking render() is the #1 production incident with this class of package. Grep your renderers for getTraceAsString, getFile, and getPrevious before shipping.withHeader()/withStatus() return new instances. Build the response once, then decorate; don't mutate and discard.application/problem+json and application/json; charset=utf-8 will not match application/json. Normalise before lookup.Middleware\ErrorHandlerMiddleware → ErrorHandler, Factory\ThrowableResponseFactory vs top-level). Pin the version and read the changelog before copying snippets from blog posts — including older Yii3 docs.ShouldntReport-style filter in the logger you inject.echo/var_dump in middleware; keep the error handler as the outermost layer and you stay safe.yiisoft/yii-web, so it is maintained rather than abandoned. The low opportunity score (4.71) suggests a settled API — few missing features, also few fast fixes. Vendor-pin it and don't expect rapid upstream changes.ThrowableRendererInterface for bodies, ThrowableResponseFactoryInterface for status/headers, and a logging decorator for filtering. Those three cover ~95% of what Laravel developers reach for Handler::render()/report()/renderable() to do.How can I help you explore Laravel packages today?