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
Monolog version 3.9.0
Related issue: #1096 and #1106
Summary
handleExceptionguards its call tohttp_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 spuriousE_WARNING:The three phases
Phase 1 — No status code set yet
Neither
header('HTTP/...')norhttp_response_code()has been called.headers_sent()returnsfalse. Callinghttp_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, soheaders_sent()still returnsfalse— 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 (theheader()value wins) and PHP emits anE_WARNING. There is no PHP API to detect this state from userland without inspectingheaders_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()returntrue. Bothheader()andhttp_response_code()become meaningless at this point.Why the current guard is insufficient
!headers_sent()istruein 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
handleExceptionis invoked from a registered exception handler — which can fire at any point during request processing — it is impossible for user code to guarantee that noheader('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 callinghttp_response_code():Alternatively, a simpler one-liner using
array_filter:References
SG(headers_sent)inSAPI.h— the flag thatheaders_sent()reads; set only when headers are actually written to the output stream.http_response_code()implementation inhead.c— checks onlySG(headers_sent), not whether a status line is already staged inSG(sapi_headers).headers.header('HTTP/...')andhttp_response_code().