Skip to content

E_WARNING from http_response_code() in handleException when a status line has been registered via header() but not yet sent #2027

@tanakahisateru

Description

@tanakahisateru

Monolog version 3.9.0

Related issue: #1096 and #1106

Summary

handleException guards its call to http_response_code(500) with !headers_sent(), but this guard is insufficient. PHP maintains three distinct phases for HTTP headers, and the guard only covers two of them. When an exception is thrown during phase 2 (described below), the call produces a spurious E_WARNING:

E_WARNING: http_response_code(): Calling http_response_code() after header('HTTP/...') has no effect

The three phases

Phase 1 — No status code set yet
Neither header('HTTP/...') nor http_response_code() has been called. headers_sent() returns false. Calling http_response_code() works correctly as a "default value" that will be used if no explicit status line is ever registered.

Phase 2 — Status line registered, but not yet written to the output stream
An explicit header('HTTP/1.x NNN ...') call has added a status line to PHP's internal header list (SG(sapi_headers).headers). The headers have not been flushed to the client yet, so headers_sent() still returns false — it reflects only whether bytes have been written to the output stream, not whether a status line has been staged.

At this point, calling http_response_code() has no effect on the actual response (the header() value wins) and PHP emits an E_WARNING. There is no PHP API to detect this state from userland without inspecting headers_list() manually.

Phase 3 — Headers flushed to the output stream
Once any body output is produced, PHP flushes the staged headers first. Only now does headers_sent() return true. Both header() and http_response_code() become meaningless at this point.

Why the current guard is insufficient

// ErrorHandler.php ~L177
if (!headers_sent() && \in_array(...)) {
    http_response_code(500); // ← still fires during phase 2, causing E_WARNING
}

!headers_sent() is true in both phase 1 and phase 2. The guard therefore cannot distinguish between "safe to call" and "will produce a warning and have no effect".

Because handleException is invoked from a registered exception handler — which can fire at any point during request processing — it is impossible for user code to guarantee that no header('HTTP/...') call has occurred before the exception is thrown. This is not a usage error; it is a gap in the guard logic.

Proposed fix

Check headers_list() for an existing HTTP status line before calling http_response_code():

if (!headers_sent() && !self::hasHttpStatusHeader() && \in_array(strtolower((string) \ini_get('display_errors')), ['0', '', 'false', 'off', 'none', 'no'], true)) {
    http_response_code(500);
}

private static function hasHttpStatusHeader(): bool
{
    foreach (headers_list() as $header) {
        if (stripos($header, 'HTTP/') === 0) {
            return true;
        }
    }
    return false;
}

Alternatively, a simpler one-liner using array_filter:

$hasStatusHeader = (bool) array_filter(headers_list(), static fn($h) => stripos($h, 'HTTP/') === 0);

if (!headers_sent() && !$hasStatusHeader && \in_array(...)) {
    http_response_code(500);
}

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions