DEV Community

Cover image for get_error_handler() and get_exception_handler() in PHP 8.5
Gabriel Anhaia
Gabriel Anhaia

Posted on

get_error_handler() and get_exception_handler() in PHP 8.5


You want to add your own exception handler to an app that already has one. Sentry registered theirs during boot. Whoops registered another in the dev environment. You want to log the exception to your audit trail before either of them runs, and you do not want to break what's already there.

So you ask a simple question: what handler is registered right now? Until PHP 8.5, there was no clean way to answer it. You had to disturb the thing you were trying to inspect.

PHP 8.5 shipped get_error_handler() and get_exception_handler() in November 2025. Two functions, both read-only, both returning the current handler or null. Small addition. It closes a real gap.

The old way had a side effect

Before 8.5, the only way to read the current error handler was to set a new one and read the return value. set_error_handler() returns the handler it replaced. So the trick was to overwrite, capture, and restore:

// Pre-8.5: read by disturbing the stack
$current = set_error_handler(static fn () => false);
restore_error_handler();
Enter fullscreen mode Exit fullscreen mode

set_error_handler() pushes onto a stack. restore_error_handler() pops it. Between those two lines, your dummy handler is the live one. In a single request that window is tiny, but it's still a real state change on a global. If anything triggered an error inside that gap, the dummy caught it instead of the real handler.

The exception-handler version was worse, because there's no restore semantics that read cleanly:

// Pre-8.5: null out, read, put it back
$current = set_exception_handler(null);
set_exception_handler($current);
Enter fullscreen mode Exit fullscreen mode

For a moment, the app had no exception handler at all. If a fatal uncaught exception landed in that gap, you got PHP's default output instead of yours. Nobody hit it often. Everybody who wrote framework glue code knew it was there.

What 8.5 added

Both functions take no arguments and return ?callable:

get_error_handler(): ?callable
get_exception_handler(): ?callable
Enter fullscreen mode Exit fullscreen mode

They read. They do not push, pop, or replace anything. When no custom handler is registered and PHP's default is active, they return null.

var_dump(get_exception_handler()); // NULL

set_exception_handler(fn (Throwable $e) => error_log($e->getMessage()));

var_dump(get_exception_handler()); // Closure
Enter fullscreen mode Exit fullscreen mode

The return value is the exact callable you registered. If you registered a closure, you get that same closure instance back. If you registered [$object, 'handle'], you get that array. That identity is what makes the two use cases below work.

Composing instead of clobbering

The common mistake in bootstrap code is to call set_exception_handler() and assume you're first. You're often not. When you overwrite an existing handler without chaining to it, you silently drop whatever the previous one did: the Sentry report, the structured log, the graceful 500 page.

Now you can read the incumbent and delegate to it:

$previous = get_exception_handler();

set_exception_handler(
    function (Throwable $e) use ($previous): void {
        // your concern runs first
        audit_log($e);

        // then hand off to whoever was there
        if ($previous !== null) {
            $previous($e);
        }
    }
);
Enter fullscreen mode Exit fullscreen mode

Your handler runs its own concern, then delegates. If nothing was registered, $previous is null and PHP's default handling takes over once your closure returns. No incumbent gets dropped.

The error-handler version follows the same shape, and here the return value matters more, because error handlers signal with a boolean:

$previous = get_error_handler();

set_error_handler(
    function (
        int $errno,
        string $errstr,
        string $errfile,
        int $errline,
    ) use ($previous): bool {
        my_error_metrics()->increment($errno);

        if ($previous !== null) {
            return $previous(
                $errno, $errstr, $errfile, $errline
            );
        }

        // false lets PHP's default handler run
        return false;
    }
);
Enter fullscreen mode Exit fullscreen mode

Returning false from an error handler tells PHP to fall through to its internal handling (so error_reporting and logging behave normally). By reading $previous first and returning what it returns, you keep the chain's decision intact instead of forcing your own.

This is the pattern middleware got right years ago: wrap, do your part, call the next thing. Error and exception handlers finally support it without a global side effect during setup.

Testing your error boundary

The introspection functions are quietly at their best in tests. An error boundary is a piece of code whose whole job is to register a handler. Until 8.5, you could not assert that it did, because you had no read primitive. You tested the effect (an exception got logged) and hoped the wiring was right.

Say you have a boundary that installs a handler turning uncaught exceptions into JSON responses:

final class JsonErrorBoundary
{
    public function register(): void
    {
        set_exception_handler(
            $this->handle(...)
        );
    }

    public function handle(Throwable $e): void
    {
        http_response_code(500);
        echo json_encode([
            'error' => $e->getMessage(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the test asserts the wiring directly:

public function test_it_registers_its_handler(): void
{
    $boundary = new JsonErrorBoundary();
    $boundary->register();

    $handler = get_exception_handler();

    self::assertNotNull($handler);
    self::assertSame(
        $boundary,
        (new ReflectionFunction($handler))
            ->getClosureThis(),
    );

    restore_exception_handler();
}
Enter fullscreen mode Exit fullscreen mode

Because handle(...) is first-class-callable syntax, the registered handler is a closure bound to $boundary. ReflectionFunction::getClosureThis() gives you the bound object back, so you can assert the right instance is wired in. You're testing the registration, not a side effect three layers away.

The same trick verifies that your composition preserved the incumbent:

public function test_it_chains_to_the_previous_handler(): void
{
    $seen = [];
    set_exception_handler(
        function (Throwable $e) use (&$seen) {
            $seen[] = 'first';
        }
    );

    (new AuditBoundary())->register();

    $handler = get_exception_handler();
    self::assertNotNull($handler);

    $handler(new RuntimeException('boom'));

    self::assertContains('first', $seen);

    restore_exception_handler();
    restore_exception_handler();
}
Enter fullscreen mode Exit fullscreen mode

Call the returned handler with a fake Throwable and assert both links in the chain ran. No process crash, no separate test harness. Just a function you can now read and invoke.

Gotchas worth knowing

Clean up in tests. Handlers are process-global. If a test registers one and doesn't call restore_error_handler() / restore_exception_handler(), it leaks into the next test in the same process. Pair every set with a restore in tearDown(), and read the state back with the 8.5 functions to confirm you left it clean.

Closures don't compare by value. Two closures with identical bodies are never ==. If you need to assert a specific handler is installed, compare identity with assertSame() on the closure instance, or reflect into it as above. Don't try to match on structure.

null means default, not "unset by you." A null return says PHP's built-in handler is active. It does not distinguish "nobody ever set one" from "someone set one and restored it." If that difference matters, track it yourself.

These read; they don't lock. Between your get_* call and the following set_*, nothing stops other code from registering a handler. In a normal single-request PHP lifecycle that's a non-issue, but don't treat the read as a mutex.

Why a small function matters

Error and exception handling is the part of an app most likely to be owned by three different things at once: the framework, an observability SDK, and your own domain-level boundary. Reading the current handler without disturbing it is what lets those three coexist on purpose instead of by accident. It also lets a test say what it means: "this boundary registers its handler," in one assertion.

That's the whole shape of good boundary code. The framework and the observability vendor own the edge. Your domain stays out of it, and the wiring between the two becomes something you can inspect and assert rather than hope for. Keeping the error boundary at the edge, testable and composable, is the same instinct that runs through hexagonal architecture: the framework plugs into your app, not the other way around. If you're drawing that line in a PHP codebase, that's what Decoupled PHP is about.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)