Error and exception handler — global handlers
Concept
PHP provides two hooks that catch problems your normal code cannot: set_exception_handler() catches any Throwable that propagates all the way up the call stack without being caught, and register_shutdown_function() runs when the PHP process terminates — which includes fatal errors like out-of-memory or infinite loops that cannot throw exceptions.
Without a global exception handler, PHP will print a default error page that often leaks stack traces, file paths, and variable contents to the browser — a serious security vulnerability in production. With one, you intercept the exception, log it, and return a clean response. In development mode you can render a detailed debug page; in production you show a generic "500 Internal Server Error" message.
The relationship between the two handlers is subtle. set_exception_handler() handles uncaught exceptions (anything that implements Throwable — both Exception and Error). register_shutdown_function() handles fatal errors that PHP raises as E_ERROR rather than exceptions: ParseError (before 8.0 in some cases), TypeError in non-strict mode, stack overflow (E_ERROR). Inside the shutdown function, you call error_get_last() to check if the script ended due to a fatal error, and if so, log and emit a response.
set_error_handler() is the third hook, handling non-fatal PHP errors: E_WARNING, E_NOTICE, E_DEPRECATED. In strict-mode PHP 8.x most of these have been promoted to exceptions or warnings that your exception handler catches. The recommended approach is to convert all errors to ErrorException instances: throw new \ErrorException($message, 0, $severity, $file, $line). This means your normal try/catch and exception handler covers everything.
Laravel implements this in Illuminate\Foundation\Bootstrap\HandleExceptions. It registers all three handlers and routes them through the Handler contract (Illuminate\Contracts\Debug\ExceptionHandler), which provides a clean report() (log to Sentry, etc.) and render() (HTTP response) separation.
Code Example
<?php
declare(strict_types=1);
namespace Lumen\Foundation\Bootstrap;
use Lumen\Foundation\Application;
/**
* Registers global error, exception, and shutdown handlers.
*
* Must be called early in the bootstrap sequence — before any
* other code that might throw. Specifically: after autoloading
* is set up, but before environment or config loading.
*
* Laravel equivalent: Illuminate\Foundation\Bootstrap\HandleExceptions
*/
class HandleExceptions
{
private Application $app;
public function bootstrap(Application $app): void
{
$this->app = $app;
// Convert PHP errors into ErrorException so they flow through
// the normal exception pipeline.
set_error_handler([$this, 'handleError']);
// Catch any Throwable that escapes all try/catch blocks.
set_exception_handler([$this, 'handleException']);
// Catch fatal errors (E_ERROR, E_PARSE, etc.) that cannot throw.
register_shutdown_function([$this, 'handleShutdown']);
// In production, never show errors to the browser.
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
}
/**
* Convert a PHP error into an ErrorException.
*
* @throws \ErrorException
*/
public function handleError(
int $level,
string $message,
string $file = '',
int $line = 0
): bool {
// Respect the @ operator — if error_reporting is 0, suppress.
if (error_reporting() === 0) {
return false;
}
throw new \ErrorException($message, 0, $level, $file, $line);
}
/**
* Handle an uncaught Throwable: log it and emit an error response.
*/
public function handleException(\Throwable $e): void
{
$this->reportException($e);
$this->renderException($e);
}
/**
* Called on PHP shutdown — check for fatal errors.
*/
public function handleShutdown(): void
{
$error = error_get_last();
if ($error !== null && $this->isFatal($error['type'])) {
$this->handleException(new \ErrorException(
$error['message'],
0,
$error['type'],
$error['file'],
$error['line']
));
}
}
private function isFatal(int $type): bool
{
return in_array($type, [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE], true);
}
private function reportException(\Throwable $e): void
{
// In a real implementation, resolve the ExceptionHandler from the
// container and call $handler->report($e).
// For now, write to PHP's error log.
error_log((string) $e);
}
private function renderException(\Throwable $e): void
{
// Determine if we are in debug mode.
$debug = env('APP_DEBUG', false) === true;
if ($debug) {
// Developer-friendly: show exception class, message, file, line.
$output = sprintf(
"[%s] %s in %s on line %d\n%s",
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
);
} else {
// Production: generic message, no internal details.
$output = "500 Internal Server Error\n";
}
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
}
echo $output;
}
}Interview Q&A
Q: Why convert PHP errors to ErrorException using set_error_handler()?
PHP has two error reporting mechanisms: the old E_* system (E_WARNING, E_NOTICE, etc.) and the modern exception system. When errors are in two different systems, you need two different error-handling strategies — error_reporting checks in one place, try/catch in another. Converting E_WARNING and friends to ErrorException instances unifies them: all problems become Throwable and flow through a single set_exception_handler(). It also means you can use try/catch to recover from things like a failed include (which fires E_WARNING by default): try { include $file; } catch (\ErrorException $e) { /* handle */ }. Laravel has done this conversion since version 4.
Q: What is the difference between set_exception_handler() and a try/catch around your entire application?
Conceptually they do the same thing — catch anything that escapes. The practical difference is that set_exception_handler() works even for code that runs outside your try/catch scope: middleware stack unwinding, destructor calls, and code that runs during shutdown. A try/catch around $kernel->handle($request) would miss exceptions thrown during $kernel->terminate() or in object destructors called during garbage collection. set_exception_handler() is the true last resort. The other difference: inside a set_exception_handler() callback, you cannot throw another exception (it becomes a fatal error), so the handler must be defensive and never throw.
Q: register_shutdown_function() is called when PHP shuts down normally too, not just on errors. How do you distinguish the two cases?
You call error_get_last() inside the shutdown function. If PHP exited normally, error_get_last() returns null (or returns the last error from earlier in the request that was already handled). If PHP exited due to a fatal error (E_ERROR, E_PARSE, E_COMPILE_ERROR, E_CORE_ERROR), error_get_last() returns an array with type, message, file, and line. The isFatal() check filters to only these fatal types. This is why register_shutdown_function() is paired with error_get_last() — they are the only way to detect fatal errors at the PHP level.