A best-effort, last-resort JSON envelope for the PHP fatals and uncaught exceptions your framework's own error handling never gets a chance to see — not a replacement for it.
A fatal — a timeout, a memory limit, an autoload failure — is not a
Throwable, so no try/catch reaches it, and that includes the
try/catch a PSR-15 pipeline or a framework's own error middleware
wraps around dispatch. Left unhandled, PHP writes an HTML error page into a
response body the client expected as JSON. This library installs an
exception handler and a shutdown handler that both answer with the same
JSON contract, so that page never reaches the client.
"Best-effort" is deliberate, not modesty. register_shutdown_function()
observes some fatals — memory_limit, max_execution_time, a parse error
— because PHP still runs a termination phase for them. It observes nothing
from a SIGKILL, an OOM-killed process, a segfault, or the infrastructure
simply terminating the worker: PHP itself never resumes to run the
shutdown function in those cases. This library answers what PHP's
termination phase gives it a chance to answer — no library can promise
more than that against a process that never comes back.
composer require cleatsquad/php-error-boundaryRequires PHP 8.2 or later. No runtime dependencies.
use CleatSquad\ErrorBoundary\ErrorBoundary;
ErrorBoundary::install();Every uncaught exception and every fatal error (E_ERROR, E_PARSE,
E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR) now answers with a JSON
body instead of an HTML one — a 504 for a timeout, a 500 for anything else.
use CleatSquad\ErrorBoundary\ErrorResponseMapperInterface;
final class MyMapper implements ErrorResponseMapperInterface
{
public function map(array $error): array
{
// $error = ['type' => int, 'message' => string, 'file'?: string, 'line'?: int]
return [500, ['error' => ['code' => 'internal_error', 'message' => 'Something went wrong.']]];
}
}
ErrorBoundary::install(new MyMapper());ErrorBoundary::install(null, $psr3Logger);When a PSR-3 LoggerInterface is supplied, the uncaught-exception line goes
through $logger->error() (the exception is passed as ['exception' => $e]
context) instead of error_log(). Fatals caught by the shutdown handler are
not logged here — PHP has already written them to the error log itself by
the time it fires.
ErrorBoundary::uninstall();Restores PHP's default exception handler and makes the boundary inert for
the shutdown handler already registered (PHP has no
unregister_shutdown_function(), so it stays registered but becomes a
no-op). Clears the mapper, logger and shutdown interceptor, so a later
install() starts clean. Useful in tests, or when handing control back to
another error-handling system for the rest of the process.
Useful for a response format the boundary doesn't own by default — an active Server-Sent Events stream, for instance, where the error must be framed as an event rather than a fresh HTTP response.
ErrorBoundary::setShutdownInterception(function (array $error): bool {
// Return true to skip the standard JSON emit — you already answered.
return false;
});Full, runnable-style snippets for common setups live in
examples/:
examples/basic.php— installing at the entry point of a plain PHP script.examples/psr3-logger.php— wiring a PSR-3 logger (Monolog, or any other implementation).examples/slim.php— a Slim 4 application, with a/crashroute (a normal exception, answered by Slim's own error middleware) next to a/fatalroute (a genuine, reproducible PHP fatal, answered by this library instead) — the split this library is for.examples/sse-interception.php— intercepting a fatal mid-stream on an active Server-Sent Events response.
No stack trace, no file path, ever reaches the client. The default
mapper returns a fixed internal_error/upstream_timeout code and a
generic message — never $error['message'] verbatim, which could leak an
internal path or class name.
Fail-open by design. If headers_sent() is already true, emit()
does nothing rather than corrupt a response that may already be partially
written.
One static boundary per process. The handler is process-global by
construction — that's what lets it catch a fatal PHP itself doesn't let you
catch. Call install() once, at the entry point.
This is not a PSR-15 middleware, and it cannot be one. PSR-15
(MiddlewareInterface) relies on the call stack staying alive — a
try/catch around $handler->handle($request) — to intercept an error.
A PHP fatal (E_ERROR, a memory_limit exhaustion, a
max_execution_time timeout) is not a Throwable: no try/catch
anywhere, PSR-15 pipeline included, ever sees it. PHP terminates execution
immediately, and the call stack — the pipeline itself — is gone with it.
The only hook PHP leaves after that is register_shutdown_function(), a
process-global termination phase with no pipeline left to resume. That's a
constraint of the language, not a design choice: no amount of engineering
turns "catch a fatal" into a composable middleware object.
Concretely:
- For exceptions your framework's dispatch actually catches, its own
PSR-15 error middleware (Slim's
ErrorMiddleware, Mezzio's, etc.) already handles them — this library does not replace that. - This library exists for what that middleware structurally cannot see: a fatal that kills the process before any middleware, PSR-15 or not, gets a chance to run.
- No PSR-7 either: by the time the shutdown handler fires, the
framework's own response-emission machinery may no longer be usable,
so
emit()writes directly withheader()/echorather than building aResponseInterfacenothing may be left to send. - PSR-3 (
LoggerInterface) is supported for the optional logger — that boundary is a plain interface call, not something the language restricts.
composer install
composer test # PHPUnit
composer analyse # PHPStan, max levelMIT. See LICENSE.