Skip to content

Commit bdd8f1b

Browse files
dunglasnicolas-grekas
authored andcommitted
[HttpFoundation] Add support for the 103 status code (Early Hints) and other 1XX statuses
1 parent 3a445e6 commit bdd8f1b

File tree

5 files changed

+81
-7
lines changed

5 files changed

+81
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add `ParameterBag::getEnum()`
88
* Create migration for session table when pdo handler is used
99
* Add support for Relay PHP extension for Redis
10+
* The `Response::sendHeaders()` method now takes an optional HTTP status code as parameter, allowing to send informational responses such as Early Hints responses (103 status code)
1011

1112
6.2
1213
---

Response.php

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ class Response
211211
511 => 'Network Authentication Required', // RFC6585
212212
];
213213

214+
/**
215+
* Tracks headers already sent in informational responses.
216+
*/
217+
private array $sentHeaders;
218+
214219
/**
215220
* @param int $status The HTTP status code (200 "OK" by default)
216221
*
@@ -326,30 +331,71 @@ public function prepare(Request $request): static
326331
/**
327332
* Sends HTTP headers.
328333
*
334+
* @param null|positive-int $statusCode The status code to use, override the statusCode property if set and not null
335+
*
329336
* @return $this
330337
*/
331-
public function sendHeaders(): static
338+
public function sendHeaders(/* int $statusCode = null */): static
332339
{
333340
// headers have already been sent by the developer
334341
if (headers_sent()) {
335342
return $this;
336343
}
337344

345+
$statusCode = \func_num_args() > 0 ? func_get_arg(0) : null;
346+
$informationalResponse = $statusCode >= 100 && $statusCode < 200;
347+
if ($informationalResponse && !\function_exists('headers_send')) {
348+
// skip informational responses if not supported by the SAPI
349+
return $this;
350+
}
351+
338352
// headers
339353
foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) {
340-
$replace = 0 === strcasecmp($name, 'Content-Type');
341-
foreach ($values as $value) {
354+
$newValues = $values;
355+
$replace = false;
356+
357+
// As recommended by RFC 8297, PHP automatically copies headers from previous 103 responses, we need to deal with that if headers changed
358+
if (103 === $statusCode) {
359+
$previousValues = $this->sentHeaders[$name] ?? null;
360+
if ($previousValues === $values) {
361+
// Header already sent in a previous response, it will be automatically copied in this response by PHP
362+
continue;
363+
}
364+
365+
$replace = 0 === strcasecmp($name, 'Content-Type');
366+
367+
if (null !== $previousValues && array_diff($previousValues, $values)) {
368+
header_remove($name);
369+
$previousValues = null;
370+
}
371+
372+
$newValues = null === $previousValues ? $values : array_diff($values, $previousValues);
373+
}
374+
375+
foreach ($newValues as $value) {
342376
header($name.': '.$value, $replace, $this->statusCode);
343377
}
378+
379+
if ($informationalResponse) {
380+
$this->sentHeaders[$name] = $values;
381+
}
344382
}
345383

346384
// cookies
347385
foreach ($this->headers->getCookies() as $cookie) {
348386
header('Set-Cookie: '.$cookie, false, $this->statusCode);
349387
}
350388

389+
if ($informationalResponse) {
390+
headers_send($statusCode);
391+
392+
return $this;
393+
}
394+
395+
$statusCode ??= $this->statusCode;
396+
351397
// status
352-
header(sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText), true, $this->statusCode);
398+
header(sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode);
353399

354400
return $this;
355401
}

StreamedResponse.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,22 @@ public function setCallback(callable $callback): static
5959
/**
6060
* This method only sends the headers once.
6161
*
62+
* @param null|positive-int $statusCode The status code to use, override the statusCode property if set and not null
63+
*
6264
* @return $this
6365
*/
64-
public function sendHeaders(): static
66+
public function sendHeaders(/* int $statusCode = null */): static
6567
{
6668
if ($this->headersSent) {
6769
return $this;
6870
}
6971

70-
$this->headersSent = true;
72+
$statusCode = \func_num_args() > 0 ? func_get_arg(0) : null;
73+
if ($statusCode < 100 || $statusCode >= 200) {
74+
$this->headersSent = true;
75+
}
7176

72-
return parent::sendHeaders();
77+
return parent::sendHeaders($statusCode);
7378
}
7479

7580
/**

Tests/ResponseTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ public function testSendHeaders()
4242
$this->assertSame($response, $headers);
4343
}
4444

45+
public function testSendInformationalResponse()
46+
{
47+
$response = new Response();
48+
$response->sendHeaders(103);
49+
50+
// Informational responses must not override the main status code
51+
$this->assertSame(200, $response->getStatusCode());
52+
53+
$response->sendHeaders();
54+
}
55+
4556
public function testSend()
4657
{
4758
$response = new Response();

Tests/StreamedResponseTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,15 @@ public function testSetNotModified()
124124
$string = ob_get_clean();
125125
$this->assertEmpty($string);
126126
}
127+
128+
public function testSendInformationalResponse()
129+
{
130+
$response = new StreamedResponse();
131+
$response->sendHeaders(103);
132+
133+
// Informational responses must not override the main status code
134+
$this->assertSame(200, $response->getStatusCode());
135+
136+
$response->sendHeaders();
137+
}
127138
}

0 commit comments

Comments
 (0)