DEV Community

Cover image for PHP 8.5 Finally Gives Fatal Errors a Backtrace. Here's Why It Matters
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP 8.5 Finally Gives Fatal Errors a Backtrace. Here's Why It Matters


It's 2 a.m. and a worker keeps dying. The log says one thing:

Fatal error: Allowed memory size of 134217728 bytes
exhausted (tried to allocate 20480 bytes)
in /app/src/Report/Exporter.php on line 88
Enter fullscreen mode Exit fullscreen mode

You open Exporter.php:88. It's a foreach over an array. Nothing there allocates 128 MB. The line where PHP finally ran out of memory is almost never the line that ate it. So you start guessing: which job called this, with what input, through how many layers of service code? You add logging, redeploy, and wait for it to happen again.

That guessing game is over in PHP 8.5. Fatal errors now print a full backtrace.

The error message you used to get

Before 8.5, an uncaught Exception gave you a stack trace and a fatal error gave you nothing but a location. Two very different debugging experiences for two problems that feel identical from the outside.

The reason is architectural. Exceptions unwind the stack in userland, so PHP has the call chain in hand. Fatal errors like memory exhaustion or a hit max_execution_time are raised deep in the engine, at a point where the old error path just printed the message and bailed. No stack was captured. You got the address of the crash, never the road that led there.

For an out-of-memory kill or a timeout, the crash site is the least useful line in the program. It's wherever the allocator happened to say no.

What PHP 8.5 changed

The Error Backtraces v2 RFC by Eric Norris (19 votes to 1) added backtrace capture to the fatal error path in the Zend engine. Same crash, PHP 8.5:

Fatal error: Allowed memory size of 134217728 bytes
exhausted (tried to allocate 20480 bytes)
in /app/src/Report/Exporter.php on line 88
Stack trace:
#0 /app/src/Report/Exporter.php(88): implode(', ', Array)
#1 /app/src/Report/Exporter.php(41): Exporter->row()
#2 /app/src/Report/RunReport.php(60): Exporter->export()
#3 /app/bin/worker.php(29): RunReport->handle()
#4 {main}
Enter fullscreen mode Exit fullscreen mode

Now the line number is context, not the whole story. You can see worker.php triggered RunReport::handle(), which called export(), which looped rows until the heap gave out. The bug is the unbounded row buffer in export(), not line 88. You'd have found that in ten seconds instead of two redeploys.

Which errors get a trace now

The feature covers the error levels in the E_FATAL_ERRORS group, which is the class of problems that used to terminate silently:

  • Memory exhaustionAllowed memory size ... exhausted.
  • Execution timeoutsMaximum execution time of N seconds exceeded.
  • Calls to undefined functions and methods.
  • Uncaught type errors and other engine-level fatals.

The two that matter most in production are the first two. A memory kill and a timeout are the classic "it works on my machine, it dies under real load" failures, and they're exactly the ones that never carried a stack. Here's a timeout you can reproduce:

<?php
// timeout-demo.php — run with: php -d max_execution_time=1
function chew(int $depth): void
{
    if ($depth > 0) {
        chew($depth - 1);
    }
    while (true) {
        // busy loop that outlives the time limit
    }
}

chew(3);
Enter fullscreen mode Exit fullscreen mode

On 8.4 the timeout tells you the loop is the loop. On 8.5 the trace shows the full chew(3) → chew(2) → chew(1) → chew(0) recursion chain down to the frame stuck in the loop, which is the part you actually needed when the real call stack is fifteen frames of framework code.

Reading the trace from code

The backtrace isn't only printed. It's attached to the error, so a shutdown handler can read it through error_get_last(). The array now carries a trace key:

<?php
register_shutdown_function(function (): void {
    $error = error_get_last();

    if ($error === null) {
        return;
    }

    // 'trace' is populated on 8.5 for fatal errors
    $trace = $error['trace'] ?? [];

    error_log(json_encode([
        'type'    => $error['type'],
        'message' => $error['message'],
        'file'    => $error['file'],
        'line'    => $error['line'],
        'frames'  => count($trace),
    ]));
});
Enter fullscreen mode Exit fullscreen mode

This is the hook that matters for observability. If you ship structured logs to something like Sentry, Bugsnag, or an OpenTelemetry collector, you can now forward the call stack of an OOM the same way you already forward exception traces. Before 8.5 your monitoring saw fatal errors as untraceable one-liners. Now they carry the same shape as everything else.

What gets redacted

A stack trace with arguments can leak secrets. The engine handles this the same way it already handles exception traces, so the rules you know still apply.

Mark sensitive parameters and their values drop out of the trace:

<?php
function login(
    string $email,
    #[\SensitiveParameter] string $password,
): void {
    // if a fatal fires below this frame, $password
    // shows as Object(SensitiveParameterValue)
}
Enter fullscreen mode Exit fullscreen mode

And if you want arguments stripped from every fatal backtrace across the board, the existing zend.exception_ignore_args INI setting covers these too. Turn it on and frames show function names and files without the argument values.

; php.ini
fatal_error_backtraces = 1        ; the new switch, on by default
zend.exception_ignore_args = 1    ; drop args from traces in prod
Enter fullscreen mode Exit fullscreen mode

The limitation worth knowing

Capturing a trace means holding references to the arguments in every frame. The RFC is explicit about it: a backtrace that contains arguments increments the refcount of those arguments, so they stay alive until the backtrace is destroyed. That's why the feature is scoped to fatal errors only — abnormal, terminal situations where keeping a bit of extra memory alive for a moment doesn't matter, because the process is on its way out anyway.

It also means the trace is a best effort during a memory kill. You're capturing a stack at the exact moment the allocator refused more memory. In practice it works because PHP reserves a small headroom for the shutdown path, but don't be surprised if a trace during a severe OOM is shorter than you'd like. It's still far more than the nothing you had before.

What to turn on

fatal_error_backtraces defaults to 1, so on PHP 8.5 you already have it. Two settings shape the production experience:

  • Keep display_errors = Off in production. It suppresses the on-screen trace along with the message, exactly as before. The trace still reaches your logs and error_get_last().
  • Set log_errors = On so the backtrace lands in your error log, where a human or a shipper can read it.
  • Turn on zend.exception_ignore_args if your traces might carry tokens, passwords, or PII you don't want in logs.

That's the whole setup. A language feature that removes an entire category of guesswork, and the migration cost is reading one paragraph of release notes.


If this was useful

A fatal error backtrace is only readable when the call chain reflects the shape of your system: a use case calling a domain service calling a repository, each frame named for what it does. When your business logic is tangled into controllers and framework glue, the trace is fifteen frames of vendor code hiding the two that matter. Keeping infrastructure concerns at the edge and the domain in the middle is what makes a crash legible, and it's the thread Decoupled PHP pulls on from the first chapter.

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)