An exception that should have reached Sentry never arrived. Nothing in the logs, nothing in the dashboard, and the stack trace you wanted is gone. Something between bootstrap/app.php and your controller replaced the error handler, and until PHP 8.5 there was no supported way to look at it — no get_error_handler(), no way to ask PHP what was actually installed.
Reading a handler used to mean writing one twice#
set_error_handler() has always returned the previous handler. That is the whole reason the workaround exists — PHP knew the value, it just never gave you a way to read it without setting something first.
// Pre-8.5: the only supported way to read the current error handler.
$handler = set_error_handler(null); // write #1 — nothing is installed now
restore_error_handler(); // write #2 — pops the stack back
var_dump($handler);
Count the problems. It is a read implemented as two writes. Between those two lines the application has no error handler at all, so anything raised in the gap falls through to the engine default. And if the code between them throws — a logger that blows up, a ReflectionException while you inspect the value — the restore never runs and you have not inspected the handler, you have destroyed it.
The variant people usually reach for first is worse:
$handler = set_error_handler(null);
// ...
set_error_handler($handler); // looks symmetric, is not
set_error_handler() pushes onto an internal stack that restore_error_handler() pops. Setting the old handler back instead of restoring leaves the stack one level deeper than you found it, so the next restore_error_handler() further up the call stack pops the wrong thing. That failure surfaces nowhere near the code that caused it.
PHP 8.5 shipped a pile of things that make debugging less of an archaeology exercise — full stack traces on fatal errors being the headline. Handler introspection is the quiet one, and it is the one I have used most.
get_error_handler() and get_exception_handler()#
Two functions, no arguments, no side effects:
function get_error_handler(): ?callable {}
function get_exception_handler(): ?callable {}
Both return the currently registered handler, or null when PHP's default is in place. The whole read becomes one line:
$handler = get_error_handler(); // no mutation, no window, no finally needed
The RFC passed 28–0 and landed in 8.5. The guarantee worth remembering is that you get back the exact callback value that was passed to set_error_handler() — not a normalised or wrapped copy — which is what makes identity comparison useful.
What get_error_handler() returns for each handler shape#
People assume PHP normalises callables into some canonical form. It does not. Run this on 8.5 and you get the value you handed in, unchanged:
<?php
// handler-shapes.php
final class Reporter
{
public function __invoke(int $errno, string $message): bool { return true; }
public function handle(int $errno, string $message): bool { return true; }
}
function legacy_handler(int $errno, string $message): bool { return true; }
$reporter = new Reporter();
$shapes = [
'closure' => static fn (int $errno, string $message): bool => true,
'invokable' => $reporter,
'array callable' => [$reporter, 'handle'],
'function name' => 'legacy_handler',
'first-class' => legacy_handler(...),
];
foreach ($shapes as $label => $candidate) {
set_error_handler($candidate);
printf(
"%-15s => %-10s identical: %s\n",
$label,
get_debug_type(get_error_handler()),
var_export(get_error_handler() === $candidate, true),
);
restore_error_handler(); // keep the stack balanced
}
set_error_handler(null);
var_dump(get_error_handler()); // NULL — engine default is back
closure => Closure identical: true
invokable => Reporter identical: true
array callable => array identical: true
function name => string identical: true
first-class => Closure identical: true
NULL
The first-class row is the interesting one. legacy_handler(...) builds a fresh Closure each time you write it, so identity holds against the variable you stored but not against a second legacy_handler(...) written elsewhere. That bites in tests, and I come back to it in the gotchas.
There is still no handler stack#
This is the first thing everyone assumes and it is not true. PHP maintains a stack internally — that is what restore_error_handler() pops — but there is no get_error_handler_stack(). Both new functions show you the top entry and nothing else.
So you can answer "what is handling errors right now?" but not "how deep am I?" or "who was here before?". If you need that, you have to record it yourself on the way in.
Safely borrowing the handler#
A profiler, a test helper, a deprecation collector — anything that installs a handler temporarily — now has a correct pattern available:
/**
* Run $work with $handler installed, then put back whatever was there before.
*
* @template TReturn
*
* @param callable(int, string, string, int): bool $handler
* @param callable(): TReturn $work
* @return TReturn
*/
function withTemporaryErrorHandler(callable $handler, callable $work): mixed
{
$previous = get_error_handler();
set_error_handler($handler);
try {
return $work();
} finally {
restore_error_handler();
// If $work pushed a handler and never popped it, the restore above
// gave us that leaked handler instead of ours. Force the issue.
if (get_error_handler() !== $previous) {
set_error_handler($previous);
}
}
}
That defensive second step is only writable on 8.5, because before it you could not check.
Which of the two restores is correct depends on what you know. restore_error_handler() is right when you pushed exactly once and you trust everything beneath you to be balanced — it keeps the stack depth honest. Explicit set_error_handler($previous) is right when you cannot be sure, and it costs you an extra stack level to buy a handler you can actually name. I default to restore_error_handler() with the verification above, because the depth only matters if someone further out is also restoring, and the verification catches the case where they are not.
Finding what hijacked Laravel's handler#
Laravel installs its handlers in Illuminate\Foundation\Bootstrap\HandleExceptions::bootstrap(), very early:
error_reporting(-1);
set_error_handler($this->forwardsTo('handleError'));
set_exception_handler($this->forwardsTo('handleException'));
register_shutdown_function($this->forwardsTo('handleShutdown'));
forwardsTo() returns an arrow function bound to the bootstrapper instance, so get_error_handler() on a clean Laravel 13 app gives you a Closure — not [HandleExceptions::class, 'handleError']. Reflect it and the binding tells you everything:
namespace App\Providers;
use Closure;
use Illuminate\Support\ServiceProvider;
use ReflectionFunction;
class HandlerAuditServiceProvider extends ServiceProvider
{
public function boot(): void
{
logger()->debug('error handler owner', self::describeErrorHandler());
}
/**
* @return array{type: string, owner: string|null, file: string|null, line: int|null}
*/
public static function describeErrorHandler(): array
{
$handler = get_error_handler();
if ($handler === null) {
return ['type' => 'engine-default', 'owner' => null, 'file' => null, 'line' => null];
}
if ($handler instanceof Closure) {
$reflection = new ReflectionFunction($handler);
$bound = $reflection->getClosureThis(); // Laravel binds to HandleExceptions
return [
'type' => 'closure',
'owner' => $bound !== null ? $bound::class : null,
'file' => $reflection->getFileName(),
'line' => $reflection->getStartLine(),
];
}
if (is_array($handler)) {
return [
'type' => 'array-callable',
'owner' => is_object($handler[0]) ? $handler[0]::class : $handler[0],
'file' => null,
'line' => null,
];
}
return [
'type' => get_debug_type($handler),
'owner' => is_object($handler) ? $handler::class : (string) $handler,
'file' => null,
'line' => null,
];
}
}
A healthy application logs this:
owner: Illuminate\Foundation\Bootstrap\HandleExceptions
file: vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php
A hijacked one logs the file of whatever replaced it — usually an unbound closure inside a vendor package, with owner coming back null:
type: closure
owner: null
file: vendor/acme/profiler/src/Profiler.php
line: 61
Now you have a filename. Call describeErrorHandler() at three points — at the end of bootstrap/app.php, in the boot() of a provider registered last, and inside the failing request — and the first point where the owner stops being HandleExceptions is the culprit. That is the whole diagnosis, and it takes about five minutes instead of an afternoon of git bisect against your composer.lock.
The same reflection trick works on get_exception_handler(), which is the one that matters when reporting to Sentry, Flare or Nightwatch goes quiet. If exceptions are reaching Laravel but not reaching your reporter, the handler is fine and the problem is further in — that is a reporting pipeline question rather than a handler question, and knowing which of the two you are looking at is most of the work. It is also worth checking that the exception itself is not quietly deciding its own fate through a report() method returning false.
Keeping handlers clean in tests#
Test suites leak handlers constantly. An error-to-exception converter, a deprecation collector, a helper that installs a handler and asserts on what it caught — any of them that forgets to restore poisons every test that runs after it, and the failure shows up somewhere unrelated.
A pair of hooks in tests/Pest.php makes the leak fail at the source:
// tests/Pest.php
beforeEach(function (): void {
$this->handlersAtStart = [get_error_handler(), get_exception_handler()];
});
afterEach(function (): void {
expect([get_error_handler(), get_exception_handler()])
->toBe($this->handlersAtStart);
});
toBe() is strict identity, which is exactly what you want here: Laravel builds its handler closures once per application boot, so within a single test the object identity is stable and any difference means something replaced them and did not put them back. If you find yourself writing this assertion in several places, it belongs in a custom expectation rather than copy-pasted hooks.
For code that still has to run on 8.3 or 8.4, guard the declaration — defining a global get_error_handler() on 8.5 is a fatal error:
// src/Support/handlers.php, wired through composer.json "autoload.files"
if (! function_exists('get_error_handler')) {
function get_error_handler(): ?callable
{
try {
return set_error_handler(null);
} finally {
restore_error_handler();
}
}
}
if (! function_exists('get_exception_handler')) {
function get_exception_handler(): ?callable
{
try {
return set_exception_handler(null);
} finally {
restore_exception_handler();
}
}
}
That is still two writes for a read. The finally is what makes it survivable rather than correct. If you are planning the jump anyway, the 8.5 deprecations worth clearing first are a better use of an afternoon than nursing this fallback.
Gotchas and Edge Cases#
Closure identity is not stable across boots. forwardsTo() builds a new arrow function every call, and first-class callable syntax builds a new Closure every time it is written. Comparing a handler captured in one request to one captured in another with === will report a difference that does not exist. Compare getFileName() and getStartLine() from ReflectionFunction instead.
get_exception_handler() returning null in a test means less than you think. PHPUnit restores handlers around test execution, so a null inside a test body can mean the runner has already unwound the handler you were looking for, not that nothing is installed.
Octane and long-lived workers carry handler state between requests. A handler leaked in request 1 is still installed for request 2, and every request after that on the same worker. This is where the diagnosis matters most and where a fresh-boot mental model actively misleads you — worth reading alongside how Octane's worker lifecycle changes state assumptions.
Array callables compare by value. [$reporter, 'handle'] === [$reporter, 'handle'] is true for the same object, so identity checks behave sensibly here — but two different Reporter instances will compare false even though both are "the reporter's handler". Reach for instanceof checks on $handler[0] when the instance is not the thing you care about.
Neither function tells you about register_shutdown_function(). Laravel's fatal-error path runs through a shutdown function, and there is no introspection for those at all. If errors vanish only on fatals, the handler is not where you should be looking.
Wrapping Up#
Add get_error_handler() to whatever debug dump your application already has, and add the beforeEach/afterEach pair to tests/Pest.php today — both cost nothing and turn "errors are disappearing somewhere" into a filename and a line number. If you are already deep in a disappearing-errors hunt, pair it with PHP 8.5's fatal error backtraces to catch the shutdown path this article cannot see, and tail the logs with Pail while you reproduce.
FAQ#
How do I get the current error handler in PHP?
On PHP 8.5 and later, call get_error_handler(). It takes no arguments, returns the currently registered handler as a callable, and does not modify anything. On earlier versions there is no read-only option: you have to call set_error_handler(null), capture the return value, and immediately call restore_error_handler() — wrapped in a try/finally so an exception in between cannot leave the application without a handler.
What does get_error_handler() return if no handler is set?
It returns null, meaning PHP's built-in default handler is active. This is the same value you get back immediately after calling set_error_handler(null). Be careful not to read null as "something went wrong" — in a framework application it usually means the handler was replaced with the default rather than never installed, which is itself the bug you are hunting.
What is the difference between get_error_handler() and restore_error_handler()?
get_error_handler() reads the top of PHP's internal handler stack and leaves the stack untouched. restore_error_handler() pops that stack, discarding the current handler and reinstating the one beneath it. One is an inspection, the other is a mutation, and they are frequently paired: capture with get_error_handler() before you install anything, then pop with restore_error_handler() afterwards and verify you landed back on the handler you captured.
How do I find out which package overrode my Laravel exception handler?
Call get_exception_handler() at several points in the boot sequence and reflect what comes back. On a clean Laravel application the handler is a Closure bound to Illuminate\Foundation\Bootstrap\HandleExceptions, so ReflectionFunction::getClosureThis() names that class and getFileName() points into the framework. As soon as the bound instance changes or getFileName() points into a vendor directory, you have the package. Log it from a service provider's boot() method, from the end of bootstrap/app.php, and from inside the failing request to narrow down where the swap happens.
Can I inspect the full error handler stack in PHP 8.5?
No. PHP maintains a stack internally so that restore_error_handler() has something to pop, but get_error_handler() exposes only the top entry and there is no function that returns the whole stack. If you need stack depth or history you have to track it yourself by recording each handler as you install it.
Is set_error_handler(null) safe to use to read the current handler?
It works, but it is a read performed by mutating global state, and on 8.5 there is no reason to do it. Between the set_error_handler(null) call and the restore, your application is running without a handler — anything raised in that window goes to the engine default. Worse, restoring by calling set_error_handler($previous) instead of restore_error_handler() pushes another entry onto the stack, so a later restore higher up the call stack pops the wrong handler. If you must support PHP 8.4 or older, use the set-and-restore pair inside a try/finally and keep the window as small as possible.