Summary
On PHP 8.0+ (Observer API), when a Laravel application's bound exception handler is a subclass that inherits report()/render() from Illuminate\Foundation\Exceptions\Handler without overriding them, the agent records the caught exception only on the first request handled by each php-fpm worker process. Every subsequent request served by the same (warm) worker is silently not recorded — no TransactionError, empty Errors inbox — even though the exception is thrown, handled, and logged normally by the app on every request.
This is exactly the classic app/Exceptions/Handler.php shipped by default in Laravel ≤ 10:
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
class Handler extends ExceptionHandler
{
// no report()/render() override
}
When the framework handler (Illuminate\Foundation\Exceptions\Handler) is used directly — or when the subclass overrides report()/render() — every request records correctly. The problem is specific to the inherited method.
Impact
Because production php-fpm workers are long-lived (pm = dynamic/ondemand, pm.max_requests = 0), the vast majority of caught 5xx exceptions never reach APM: only the first exception per worker lifetime is recorded. The failure is invisible (you don't get alerted about errors you don't receive), and it affects any app still on the pre-Laravel-11 handler pattern. Laravel 11/12 removed app/Exceptions/Handler.php (framework handler by default), which is why newer apps are unaffected.
Environment
- Agent: reproduced on 12.5.0.30, 12.6.0.34 and 12.8.0.37 (behaviour identical across all three).
- PHP: reproduced on 8.3.31 and 8.4.22 (NTS), glibc.
- SAPI: php-fpm. opcache on or off — no difference (see below).
- Not dependent on agent or PHP version; only on whether the bound handler defines vs inherits
report()/render().
Reproduction
- A Laravel 10 app (ships
App\Exceptions\Handler extends ExceptionHandler with no override, bound as Illuminate\Contracts\Debug\ExceptionHandler).
- php-fpm with a single, non-recycling worker to force warm-worker reuse:
pm = static
pm.max_children = 1
pm.max_requests = 0
newrelic.loglevel = verbosedebug.
- A route that throws a reportable exception:
Route::get('/boom', fn () => throw new \RuntimeException('boom'));
- Send 3 sequential requests to
/boom (all hit the same worker).
Expected
All 3 requests record a RuntimeException TransactionError.
Actual
Only the first request records. In the agent log, recording error priority=... cls='RuntimeException' appears once (request 1) and never again for the warm worker.
Confirming it is the inherited method (backend-free, via the agent log)
Using newrelic.loglevel=verbosedebug and a single static worker, calling the bound handler's report() on a fresh RuntimeException three times on the same worker:
Bound ExceptionHandler |
warm-request result |
Illuminate\Foundation\Exceptions\Handler (framework, directly) |
records on every request |
empty subclass extends Handler (inherited report()) |
records only on the 1st request per worker |
subclass that overrides report() (calls parent::report()) |
records on every request |
The log signature: the wrap fires on the request where the wraprec is newly added to the process-level name map (adding custom wrapper for '<Class>::report'), and on reused requests (reusing custom wrapper for '<Class>::report') it re-fires for a defined method but does not re-fire for an inherited one.
opcache on vs off makes no difference (tested both) — this is not an opcache/SHM issue.
Diagnosis
nr_laravel_application_boot (agent/fw_laravel.c) resolves the bound ExceptionHandler and wraps report()/render() using the concrete resolved object's class entry:
nr_laravel_add_callback_method(Z_OBJCE_P(exception_handler),
NR_PSTR("report"),
nr_laravel_exception_report);
nr_laravel_add_callback_method then registers the wrap under that concrete class name:
class_name = nr_php_class_entry_name(ce); // e.g. "App\Exceptions\Handler"
...
class_method = nr_formatf("%.*s::%.*s", ..., class_name, ..., method);
nr_php_wrap_user_function_before_after_clean(class_method, ...);
When the resolved handler is a subclass that only inherits report()/render(), the method's op_array is defined on the parent (Illuminate\Foundation\Exceptions\Handler) — ReflectionMethod::getDeclaringClass() confirms the parent. Registering the instrumentation under the subclass name is inconsistent with how the method is actually resolved/dispatched, and under OAPI the wrap ends up not being re-armed on warm workers (only the first-per-worker application takes effect). Wrapping the method under the class where it is defined (function->common.scope) makes the inherited case behave like the framework/overridden case, which re-arm correctly on every request.
Note: PR #877 improved exception-handler instrumentation for PHP 8.0+ but addressed the restore_exception_handler() / is_exception_handler flag path, not the inherited-subclass case; the concrete-class wrapping in nr_laravel_application_boot is unchanged on main.
Possible fix (direction, not validated against a build)
In nr_laravel_add_callback_method, after resolving the method, wrap it on its declaring class rather than the concrete (sub)class:
function = nr_php_find_class_method(ce, method);
if (NULL == function) { /* ...log... */ return; }
/* Wrap the method on the class where it is DEFINED, not the concrete
* subclass that may merely inherit it (classic App\Exceptions\Handler
* extends Handler). */
const zend_class_entry* def_ce =
(NULL != function->common.scope) ? function->common.scope : ce;
class_name = nr_php_class_entry_name(def_ce);
class_name_len = nr_php_class_entry_name_length(def_ce);
Summary
On PHP 8.0+ (Observer API), when a Laravel application's bound exception handler is a subclass that inherits
report()/render()fromIlluminate\Foundation\Exceptions\Handlerwithout overriding them, the agent records the caught exception only on the first request handled by each php-fpm worker process. Every subsequent request served by the same (warm) worker is silently not recorded — noTransactionError, empty Errors inbox — even though the exception is thrown, handled, and logged normally by the app on every request.This is exactly the classic
app/Exceptions/Handler.phpshipped by default in Laravel ≤ 10:When the framework handler (
Illuminate\Foundation\Exceptions\Handler) is used directly — or when the subclass overridesreport()/render()— every request records correctly. The problem is specific to the inherited method.Impact
Because production php-fpm workers are long-lived (
pm = dynamic/ondemand,pm.max_requests = 0), the vast majority of caught 5xx exceptions never reach APM: only the first exception per worker lifetime is recorded. The failure is invisible (you don't get alerted about errors you don't receive), and it affects any app still on the pre-Laravel-11 handler pattern. Laravel 11/12 removedapp/Exceptions/Handler.php(framework handler by default), which is why newer apps are unaffected.Environment
report()/render().Reproduction
App\Exceptions\Handler extends ExceptionHandlerwith no override, bound asIlluminate\Contracts\Debug\ExceptionHandler).newrelic.loglevel = verbosedebug./boom(all hit the same worker).Expected
All 3 requests record a
RuntimeExceptionTransactionError.Actual
Only the first request records. In the agent log,
recording error priority=... cls='RuntimeException'appears once (request 1) and never again for the warm worker.Confirming it is the inherited method (backend-free, via the agent log)
Using
newrelic.loglevel=verbosedebugand a single static worker, calling the bound handler'sreport()on a freshRuntimeExceptionthree times on the same worker:ExceptionHandlerIlluminate\Foundation\Exceptions\Handler(framework, directly)extends Handler(inheritedreport())report()(callsparent::report())The log signature: the wrap fires on the request where the wraprec is newly added to the process-level name map (
adding custom wrapper for '<Class>::report'), and on reused requests (reusing custom wrapper for '<Class>::report') it re-fires for a defined method but does not re-fire for an inherited one.opcache on vs off makes no difference (tested both) — this is not an opcache/SHM issue.
Diagnosis
nr_laravel_application_boot(agent/fw_laravel.c) resolves the boundExceptionHandlerand wrapsreport()/render()using the concrete resolved object's class entry:nr_laravel_add_callback_methodthen registers the wrap under that concrete class name:When the resolved handler is a subclass that only inherits
report()/render(), the method's op_array is defined on the parent (Illuminate\Foundation\Exceptions\Handler) —ReflectionMethod::getDeclaringClass()confirms the parent. Registering the instrumentation under the subclass name is inconsistent with how the method is actually resolved/dispatched, and under OAPI the wrap ends up not being re-armed on warm workers (only the first-per-worker application takes effect). Wrapping the method under the class where it is defined (function->common.scope) makes the inherited case behave like the framework/overridden case, which re-arm correctly on every request.Note: PR #877 improved exception-handler instrumentation for PHP 8.0+ but addressed the
restore_exception_handler()/is_exception_handlerflag path, not the inherited-subclass case; the concrete-class wrapping innr_laravel_application_bootis unchanged onmain.Possible fix (direction, not validated against a build)
In
nr_laravel_add_callback_method, after resolving the method, wrap it on its declaring class rather than the concrete (sub)class: