0

Error and exception handler — global handlers

Advanced5 min read·fw-01-007

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
<?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.