A fatal Allowed memory size exhausted at 2am used to tell me almost nothing: one message, one file, one line, and no idea which call path burned through the memory. PHP 8.5 fixes that. The new PHP 8.5 fatal error backtrace prints the full stack trace for fatal errors — timeouts, out-of-memory, the uncatchable ones — and it is on by default.
What PHP 8.5 fatal error backtraces change#
For years PHP has thrown Error exceptions for most failures, and those come with a full trace you can catch and log. But a handful of conditions never did: the E_ERROR-class fatals like maximum execution time exceeded and allowed memory size exhausted. They print a message and quit. You get the file and line where PHP gave up — not the path that got you there.
PHP 8.5 attaches a backtrace to those fatal errors. It is one of several quiet quality-of-life wins in the release, alongside additions like array_first() and array_last(), and it is the one I have wanted for a decade. If you are already working through the PHP 8.5 deprecations ahead of an upgrade, this is the reward waiting on the other side.
Warnings and notices already get traces in modern apps, because most of us promote them to exceptions with set_error_handler. It was the fatal errors — the ones you cannot catch in userland — that stayed a blind spot. That is exactly what 8.5 targets.
Reading a PHP 8.5 fatal error backtrace#
Take a function that blows the memory limit:
<?php
ini_set('memory_limit', '2M');
function buildReport(): string
{
// Allocate far more than the 2 MiB limit allows.
return str_repeat('A', 1024 * 1024 * 5);
}
buildReport();
On PHP 8.4 and earlier, the crash gives you the bare minimum:
Fatal error: Allowed memory size of 2097152 bytes exhausted (tried to allocate 5242912 bytes) in report.php on line 8
On PHP 8.5, the same crash carries the trace:
Fatal error: Allowed memory size of 2097152 bytes exhausted (tried to allocate 5242912 bytes) in report.php on line 8
Stack trace:
#0 report.php(8): str_repeat('AAAAAAAAAA...', 5242880)
#1 report.php(11): buildReport()
#2 {main}
Now I can see buildReport() is the culprit, not just line 8. In a real codebase where the allocation is buried three service classes deep, that trace is the difference between a five-minute fix and an afternoon of var_dump. The same applies to timeouts: a runaway recursion or a slow loop that trips max_execution_time now shows every frame that led there.
Turning fatal_error_backtraces on and off#
The feature is controlled by a single new INI directive, fatal_error_backtraces, and it defaults to 1 (on). You do not have to do anything to get it. To pin it explicitly in your config:
; php.ini — On is the PHP 8.5 default
fatal_error_backtraces = On
Per run, flip it off with a -d flag, which is handy for reproducing an old-format error:
php -d fatal_error_backtraces=0 report.php
The trace respects display_errors. In production, where display_errors should be Off, the fatal error and its trace never reach the response — they go to your error log instead, assuming log_errors = On. That is the intended setup: rich traces in the log, nothing leaked to the user. Turning fatal_error_backtraces off entirely is rarely what you want; locking down display_errors is the real protection.
Keeping secrets out with SensitiveParameter#
A stack trace prints call arguments, and that is the part people worry about. If a fatal error fires inside a function that takes an API key or a password, will that value land in the trace? By default, yes. That is what #[\SensitiveParameter] is for — the attribute has redacted arguments in exception traces since PHP 8.2, and in 8.5 it covers fatal error backtraces too.
Mark the parameter:
<?php
function chargeCard(string $customer, #[\SensitiveParameter] string $apiKey): void
{
ini_set('memory_limit', '2M');
// Something downstream exhausts the limit and crashes.
$buffer = str_repeat('x', 1024 * 1024 * 5);
}
chargeCard('cus_123', 'sk_live_51H8xYsecret');
Now the secret is masked in the trace while the harmless argument stays visible:
Fatal error: Allowed memory size of 2097152 bytes exhausted (tried to allocate 5242912 bytes) in charge.php on line 7
Stack trace:
#0 charge.php(7): str_repeat('xxxxxxxxxx...', 5242880)
#1 charge.php(10): chargeCard('cus_123', Object(SensitiveParameterValue))
#2 {main}
$apiKey shows as Object(SensitiveParameterValue) instead of the raw token, while 'cus_123' prints normally. This is the same attribute you would reach for anywhere secrets flow through typed signatures — one more case of PHP attributes doing real work beyond Laravel's built-ins. If you want to strip every argument regardless, zend.exception_ignore_args = On does that globally, but it is a blunt instrument: you lose the useful arguments along with the sensitive ones. I prefer targeted #[\SensitiveParameter] and leave the rest of the trace intact.
Gotchas and edge cases#
You can capture the trace yourself. Fatal errors now expose their backtrace through error_get_last(), under a new trace key. Because traces are only generated for fatal errors, you will only see it in post-shutdown code — a register_shutdown_function callback:
<?php
register_shutdown_function(function (): void {
$error = error_get_last();
if ($error !== null && isset($error['trace'])) {
// Forward the fatal error and its stack trace to your logs.
error_log($error['message'] . PHP_EOL . $error['trace']);
}
});
That is how you ship fatal traces to your logging stack; pairing it with request-scoped logging context makes those post-mortems far easier to correlate.
Arguments in the trace keep objects alive. A captured backtrace increments the refcount of every argument it holds, so those objects survive until the next error or an explicit error_clear_last(). This is why the RFC scoped the feature to fatal errors only — after a fatal error your app is already tearing down, so the lifetime quirk does not matter in practice. Just do not lean on error_get_last()['trace'] as a general-purpose profiler.
It only covers true fatals. Warnings, notices, and deprecations do not get traces from this directive. If you want traces for those, promote them to exceptions with set_error_handler — which most frameworks, Laravel included, already do.
Your fatal-error assertions may need updating. If you have tests that assert on the exact text of a fatal error message, they will now see the appended stack trace. The RFC deliberately left php-src's own run-tests.php on the old format, but your application's tests are your responsibility.
Wrapping up#
PHP 8.5 fatal error backtraces are a rare upgrade win: zero code changes, on by default, and they turn the most frustrating class of production crash — the silent timeout, the out-of-memory kill — into something you can actually trace. Leave the directive on everywhere, add #[\SensitiveParameter] to any signature that carries a secret, and keep display_errors off in production.
From here, wire the traces into something you will actually read: tail them live with Laravel Pail while you reproduce a crash locally, or weigh up Telescope, Debugbar and Pulse for capturing them in staging and production.
FAQ#
What is fatal_error_backtraces in PHP 8.5?
fatal_error_backtraces is a new INI directive added in PHP 8.5 that controls whether fatal errors print a full stack trace. When it is on, E_ERROR-class failures like memory exhaustion and execution timeouts include the chain of calls that led to the crash, not just the final file and line. It defaults to 1, so the traces appear without any configuration.
Does PHP 8.5 show a stack trace for fatal errors by default?
Yes. The fatal_error_backtraces directive defaults to 1 (enabled), so a fresh PHP 8.5 install prints stack traces for fatal errors out of the box. The RFC vote settled on on-by-default so the debugging benefit reaches everyone, not only the people who opt in. You can still switch it off per environment if you need the old behaviour.
How do I hide sensitive arguments from a PHP stack trace?
Add the #[\SensitiveParameter] attribute to the parameter that carries the secret. PHP has redacted those arguments in exception traces since 8.2, and PHP 8.5 extends the same protection to fatal error backtraces. The value is replaced with Object(SensitiveParameterValue) in the trace, so passwords, tokens, and API keys stay out of your logs while the rest of the arguments remain readable.
Will fatal error backtraces leak secrets in my logs?
They can if you do nothing, because traces include call arguments. Two settings fix it: #[\SensitiveParameter] redacts individual secret arguments, and zend.exception_ignore_args = On strips all arguments globally. Keep display_errors = Off in production so traces only ever reach your log files, then protect those logs as you already should.
How do I turn off fatal error backtraces?
Set fatal_error_backtraces = Off (or 0) in your php.ini, or pass -d fatal_error_backtraces=0 for a single run. That said, disabling it is rarely the right move — if your concern is leaking internal detail in production, turning off display_errors is the recommended protection and still lets you keep full traces in your logs.