Menu
Reference
Response
final class Response
A response under construction: the value a handler returns.
Mutable and fluent: every setter returns the same Response, so calls chain. A new response is
200 OK with no body. The server fills in what the handler left out: Content-Length (or
chunked framing for a stream), Date, Server, and Connection when the connection will
close. HEAD requests get the headers of the matching GET and no body.
return (new Response(201))
->type('application/json')
->body(json_encode(['id' => 7]));There is no json() helper: encode with json_encode() and set the type yourself, so only
programs that serialize pay for the encoder.
A response has one body kind at a time. text(), html(), body(), write(), stream() and
file() replace each other (write() appends to a string body). php's own header(),
setcookie(), http_response_code() and echo also work inside a handler; the explicit
Response wins when both are used (see Server::captureEcho()).
Example
// Building responses: status, headers, cookies, redirects, content types.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$server = Server::onListener($listener)->acceptWait(0.02);
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
switch ($req->path) {
case '/created':
return (new Response())
->status(201)
->header('X-Request-Id', 'r-1')
->type('application/json')
->body(json_encode(['id' => 7]));
case '/old':
return (new Response())->redirect('/new', 301);
case '/login':
return (new Response())
->text("welcome\n")
->cookie('sid', 'a b', path: '/app', sameSite: 'Strict')
->addHeader('Set-Cookie', 'theme=dark; Path=/')
->header('X-Tmp', 'gone')
->withoutHeader('X-Tmp');
case '/page':
return (new Response())->html('<h1>Hi</h1>')->write('<p>appended</p>');
default:
return new Response(204);
}
}));
foreach (['/created', '/old', '/login', '/page', '/none'] as $path) {
$raw = fetch($port, "GET $path HTTP/1.1\r\nHost: localhost\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
echo "== $path\n";
foreach (explode("\r\n", $head) as $line) {
if (!str_starts_with($line, 'Date:') && !str_starts_with($line, 'Server:')) {
echo $line, "\n";
}
}
echo "body: $body\n";
}
$server->stop();
});Output
== /created
HTTP/1.1 201 Created
X-Request-Id: r-1
Content-Type: application/json
Content-Length: 8
Connection: close
body: {"id":7}
== /old
HTTP/1.1 301 Moved Permanently
Location: /new
Content-Length: 0
Connection: close
body:
== /login
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Set-Cookie: sid=a%20b; path=/app; HttpOnly; SameSite=Strict
Set-Cookie: theme=dark; Path=/
Content-Length: 8
Connection: close
body: welcome
== /page
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 26
Connection: close
body: <h1>Hi</h1><p>appended</p>
== /none
HTTP/1.1 204 No Content
Connection: close
body:Example
// php's header(), setcookie(), http_response_code() and echo work inside a handler.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$server = Server::onListener($listener)->acceptWait(0.02);
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
if ($req->path === '/legacy') {
header('X-Powered-By: legacy-code');
setcookie('visited', 'yes', 0, '/');
http_response_code(202);
echo "echoed body\n"; // becomes the body: the Response below has none
return new Response();
}
// The explicit API wins: Response status and body replace the ambient ones.
http_response_code(202);
echo "dropped\n";
return (new Response(201))->text("explicit body\n");
}));
foreach (['/legacy', '/explicit'] as $path) {
$raw = fetch($port, "GET $path HTTP/1.1\r\nHost: localhost\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
echo "== $path\n";
foreach (explode("\r\n", $head) as $line) {
if (!str_starts_with($line, 'Date:') && !str_starts_with($line, 'Server:')) {
echo $line, "\n";
}
}
echo $body;
}
$server->stop();
});Output
== /legacy
HTTP/1.1 202 Accepted
X-Powered-By: legacy-code
Set-Cookie: visited=yes; path=/
Content-Length: 12
Connection: close
echoed body
== /explicit
HTTP/1.1 201 Created
Content-Type: text/plain; charset=utf-8
Content-Length: 14
Connection: close
explicit bodySee also: Request, Server, Status
Properties
status#source
int $status = 200
The status code, 200 by default.
Set it through status() or the constructor. Assigning the property directly works for reading
and for tests, but does not mark the status as chosen by the handler, so an ambient
http_response_code() call can still replace it.
headers#source
readonly Headers $headers
The header block. The OBJECT is fixed; its contents are meant to change.
Methods
__construct() | Creates a response. |
status() | Sets the status code. |
header() | Sets a header, replacing every existing line with that name. |
addHeader() | Adds a header line and keeps any already set, so the field can repeat on the wire (for example Set-Cookie). |
withoutHeader() | Removes every line with this header name. |
type() | Sets the Content-Type header. |
text() | Sets a plain-text body and the type text/plain; charset=utf-8. |
html() | Sets an HTML body and the type text/html; charset=utf-8. |
body() | Sets the body without touching the Content-Type. |
write() | Appends to the body. |
stream() | Produces the body from a closure while it is being sent. |
file() | Sends a file from disk. |
isFile() | Whether the body is a file set with file(). |
filePath() | The path given to file(), or an empty string. |
fileSize() | The size in bytes of the file opened by file(), or 0. |
fileMtime() | The modification time (Unix timestamp) of the file opened by file(), or 0. |
fileEtag() | The weak ETag for the file: W/"<mtime-hex>-<size-hex>". |
redirect() | Sets a Location header and the status. |
cookie() | Queues a Set-Cookie header. |
close() | Closes the connection after this response: the server sends Connection: close and keeps no keep-alive. |
takeover() | Hands the connection to a closure once this response's head has been written. |
isTakeover() | Whether takeover() was called. |
getBody() | The string body set by text(), html(), body(), the constructor or write(). |
isStreaming() | Whether the body is produced by a closure set with stream(). |
wantsClose() | Whether close() was called. |
statusWasSet() | Whether the handler chose a status through the constructor or status(). |
__construct()#source
function __construct(int $status = 200, string $body = '')
Creates a response.
Parameters
$statusint = 200- Status code; see
Statusfor named constants. $bodystring = ''- Initial body. Content-Type is not set by this; use
text(),html()ortype().
status()#source
function status(int $c): Response
Sets the status code.
Parameters
$cint- Status code, for example
Status::NOT_FOUND.
Returns
Response — This response.
header()#source
function header(string $n, string $v): Response
Sets a header, replacing every existing line with that name.
Parameters
$nstring- Header name.
$vstring- Header value.
Returns
Response — This response.
addHeader()#source
function addHeader(string $n, string $v): Response
Adds a header line and keeps any already set, so the field can repeat on the wire (for example Set-Cookie).
Parameters
$nstring- Header name.
$vstring- Header value.
Returns
Response — This response.
withoutHeader()#source
function withoutHeader(string $n): Response
Removes every line with this header name. An absent name is not an error.
Parameters
$nstring- Header name.
Returns
Response — This response.
type()#source
function type(string $ct): Response
Sets the Content-Type header. Shorthand for header('Content-Type', $ct).
Parameters
$ctstring- Media type, with parameters if needed, for example
application/jsonortext/csv; charset=utf-8.
Returns
Response — This response.
text()#source
function text(string $s): Response
Sets a plain-text body and the type text/plain; charset=utf-8.
Parameters
$sstring- The body.
Returns
Response — This response.
html()#source
function html(string $s): Response
Sets an HTML body and the type text/html; charset=utf-8.
Parameters
$sstring- The body.
Returns
Response — This response.
body()#source
function body(string $b): Response
Sets the body without touching the Content-Type.
Combine with type().
Parameters
$bstring- The body bytes.
Returns
Response — This response.
Example
// JSON in, JSON out: there is no Response::json(), json_encode() and json_decode() do the work.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$server = Server::onListener($listener)->acceptWait(0.02);
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
if ($req->contentType() !== 'application/json') {
return (new Response(415))->text("send application/json\n");
}
$data = json_decode($req->body(), true);
if (!is_array($data) || !isset($data['name'])) {
return (new Response(400))->type('application/json')->body(json_encode(['error' => 'name required']));
}
return (new Response(201))
->type('application/json')
->body(json_encode(['greeting' => 'hello ' . $data['name'], 'method' => $req->method]));
}));
$send = function (string $type, string $json) use ($port): void {
$raw = fetch(
$port,
"POST /users HTTP/1.1\r\nHost: localhost\r\nContent-Type: $type\r\nContent-Length: " . strlen($json) . "\r\n",
$json,
);
[$head, $body] = explode("\r\n\r\n", $raw, 2);
echo strtok($head, "\r\n"), ' ', trim($body), "\n";
};
$send('application/json', '{"name":"Ada"}');
$send('application/json', '{"nope":1}');
$send('text/plain', 'Ada');
$server->stop();
});Output
HTTP/1.1 201 Created {"greeting":"hello Ada","method":"POST"}
HTTP/1.1 400 Bad Request {"error":"name required"}
HTTP/1.1 415 Unsupported Media Type send application/jsonwrite()#source
function write(string $b): Response
Appends to the body. Appending is amortized: repeated calls do not copy the body each time.
Parameters
$bstring- Bytes to append.
Returns
Response — This response.
stream()#source
function stream(callable $fn): Response
Produces the body from a closure while it is being sent.
The closure receives an Http\ChunkedWriter. There is no Content-Length: an HTTP/1.1 client
gets Transfer-Encoding: chunked, an HTTP/1.0 client gets the raw bytes and the end of the
connection ends the body. Call flush() on the writer to push bytes to the client; otherwise
they are batched. If the closure throws, the head has already been sent, so the server ends the
framing and drops the connection instead of answering 500. Streamed bodies are never
compressed, and HEAD requests do not run the closure.
return (new Response())->type('text/plain')->stream(function (Http\ChunkedWriter $w) {
$w->write("line\n");
$w->flush();
});Parameters
$fncallable- A
Closure(ChunkedWriter): void.
Returns
Response — This response.
Example
// Streaming a response body in chunks with Response::stream().
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$server = Server::onListener($listener)->acceptWait(0.02);
async(function () use ($server, $port) {
spawn(fn () => $server->serve(function (Request $req): Response {
return (new Response())->type('text/plain')->stream(function (Http\ChunkedWriter $w) {
foreach (['alpha', 'beta', 'gamma'] as $row) {
$w->write($row . "\n");
$w->flush(); // without flush() the writer batches up to 64 KiB
}
});
}));
$raw = fetch($port, "GET /rows HTTP/1.1\r\nHost: localhost\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
foreach (explode("\r\n", $head) as $line) {
if (!str_starts_with($line, 'Date:') && !str_starts_with($line, 'Server:')) {
echo $line, "\n";
}
}
echo "-- wire body\n", str_replace("\r\n", '\r\n' . "\n", $body);
echo "-- HTTP/1.0 client: no chunking, the close ends the body\n";
$raw = fetch($port, "GET /rows HTTP/1.0\r\n");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
echo strtok($head, "\r\n"), "\n", $body;
$server->stop();
});Output
HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
Connection: close
-- wire body
6\r\n
alpha
\r\n
5\r\n
beta
\r\n
6\r\n
gamma
\r\n
0\r\n
\r\n
-- HTTP/1.0 client: no chunking, the close ends the body
HTTP/1.0 200 OK
alpha
beta
gammaSee also: ChunkedWriter
file()#source
function file(string $path, ?string $type = null): Response
Sends a file from disk.
The length on the wire is the size of the file that was opened, so a file replaced during the
request cannot produce a mismatched length. On a plain TCP connection the bytes go out with
sendfile(2) and never enter the process; over TLS they are read and written in 64 KiB pieces.
The head gets Content-Type (from the extension unless set), Last-Modified, a weak ETag and
Accept-Ranges: bytes, each only if you have not set it yourself.
For a response left at 200 to a GET or HEAD, the server answers If-None-Match and
If-Modified-Since with 304 and a single Range: bytes=a-b with 206 (416 if
unsatisfiable). A response with another status is sent as is. With Server::compression() on, a
<path>.gz file that is not older than the original is sent in its place when the client
accepts gzip; a file is never compressed on the fly.
A missing, unreadable or non-regular path is a bug in the handler, not a 404: it throws. Turn
a request path into a safe file path with safePath().
Parameters
$pathstring- Path of a regular file.
$type?string = null- Content-Type to use;
nullpicks one withmimeFor().
Returns
Response — This response.
Throws
- \RuntimeException
- when the path cannot be opened or is not a regular file
Example
// Serving a file: Content-Type, ETag, conditional requests and byte ranges.
use Http\{Request, Response, Server};
use function Async\{async, spawn};
// Bind a loopback listener on a free port (no fixed port in the example).
$listener = stream_socket_server('tcp://127.0.0.1:0');
stream_set_blocking($listener, false);
$name = stream_socket_get_name($listener, false);
$port = (int) substr($name, strrpos($name, ':') + 1);
/** One request on a fresh connection; returns the raw response. */
function fetch(int $port, string $request, string $body = ''): string
{
$c = fsockopen('127.0.0.1', $port);
fwrite($c, $request . "Connection: close\r\n\r\n" . $body);
$raw = '';
while (($chunk = fread($c, 8192)) !== '' && $chunk !== false) {
$raw .= $chunk;
}
fclose($c);
return $raw;
}
$dir = sys_get_temp_dir() . '/mc_doc_file_' . getmypid();
mkdir($dir);
file_put_contents($dir . '/notes.txt', '0123456789');
$server = Server::onListener($listener)->acceptWait(0.02);
async(function () use ($server, $port, $dir) {
spawn(fn () => $server->serve(function (Request $req) use ($dir): Response {
$file = Http\safePath($dir, $req->path);
return $file === null ? (new Response(404))->text("no such file\n") : (new Response())->file($file);
}));
$get = function (string $extra = '') use ($port): array {
$raw = fetch($port, "GET /notes.txt HTTP/1.1\r\nHost: localhost\r\n$extra");
[$head, $body] = explode("\r\n\r\n", $raw, 2);
$headers = [];
foreach (explode("\r\n", $head) as $i => $line) {
if ($i > 0) {
[$k, $v] = explode(': ', $line, 2);
$headers[strtolower($k)] = $v;
}
}
return [strtok($head, "\r\n"), $headers, $body];
};
[$status, $h, $body] = $get();
echo "$status | {$h['content-type']} | {$h['accept-ranges']} | $body\n";
[$status, , $body] = $get('If-None-Match: ' . $h['etag'] . "\r\n");
echo "$status | body: [", $body, "]\n";
[$status, $h2, $body] = $get("Range: bytes=2-5\r\n");
echo "$status | {$h2['content-range']} | $body\n";
[$status, $h2] = $get("Range: bytes=50-60\r\n");
echo "$status | {$h2['content-range']}\n";
$raw = fetch($port, "GET /../etc/passwd HTTP/1.1\r\nHost: localhost\r\n");
echo strtok($raw, "\r\n"), "\n";
$server->stop();
unlink($dir . '/notes.txt');
rmdir($dir);
});Output
HTTP/1.1 200 OK | text/plain; charset=utf-8 | bytes | 0123456789
HTTP/1.1 304 Not Modified | body: []
HTTP/1.1 206 Partial Content | bytes 2-5/10 | 2345
HTTP/1.1 416 Range Not Satisfiable | bytes */10
HTTP/1.1 404 Not FoundSee also: Request
isFile()#source
function isFile(): bool
Whether the body is a file set with file().
Returns
bool — true for a file body.
filePath()#source
function filePath(): string
The path given to file(), or an empty string.
Returns
string — The path.
fileSize()#source
function fileSize(): int
The size in bytes of the file opened by file(), or 0.
Returns
int — The size.
fileMtime()#source
function fileMtime(): int
The modification time (Unix timestamp) of the file opened by file(), or 0.
Returns
int — The timestamp.
fileEtag()#source
function fileEtag(): string
The weak ETag for the file: W/"<mtime-hex>-<size-hex>". It is weak because a byte-identical copy on another host has another mtime.
Returns
string — The tag, including the quotes and the W/ prefix.
redirect()#source
function redirect(string $loc, int $code = 302): Response
Sets a Location header and the status. There is no body.
Parameters
$locstring- Target URL or path.
$codeint = 302- Redirect status: 301, 302, 303, 307 or 308.
Returns
Response — This response.
cookie()#source
function cookie(
string $n,
string $v,
int $expires = 0,
string $path = '/',
string $domain = '',
bool $secure = false,
bool $httponly = true,
string $sameSite = 'Lax'
): Response
Queues a Set-Cookie header.
Rendered by the same code as php's setcookie(), with the same attribute order. The value is
percent-encoded. Safe defaults: HttpOnly, SameSite=Lax, path /. Several cookies, and
cookies from setcookie(), all reach the client as separate lines.
Parameters
$nstring- Cookie name.
$vstring- Cookie value. An empty string deletes the cookie: an already expired
deletedcookie is sent. $expiresint = 0- Expiry as a Unix timestamp;
0makes a session cookie. $pathstring = '/'- The
Pathattribute. $domainstring = ''- The
Domainattribute; empty omits it. $securebool = false- Send only over HTTPS.
$httponlybool = true- Hide from client-side scripts. On by default.
$sameSitestring = 'Lax'- The
SameSitevalue, normallyLax,StrictorNone; empty omits the attribute.
Returns
Response — This response.
close()#source
function close(): Response
Closes the connection after this response: the server sends Connection: close and keeps no keep-alive.
Returns
Response — This response.
takeover()#source
function takeover(\Closure $fn): Response
Hands the connection to a closure once this response's head has been written. Used for protocol upgrades such as WebSocket.
The server writes the status line and headers, then calls $fn($conn, $buf, $server) with the
socket, a Buffer\ByteBuffer holding any bytes the client sent after the request head, and the
Server. The connection is closed when $fn returns and is never read as HTTP again. Only a
101 response to an HTTP/1.1 request can take over, and only if the handler read any streamed
body fully; anything else is a handler bug and the server answers 500 without calling $fn.
Parameters
$fn\Closure- A
Closure(Resource, Buffer\ByteBuffer, Server): voidthat owns the connection.
Returns
Response — This response.
See also: Server
isTakeover()#source
function isTakeover(): bool
Whether takeover() was called.
Returns
bool — true if a takeover closure is set.
getBody()#source
function getBody(): string
The string body set by text(), html(), body(), the constructor or write(). Empty for a stream or a file.
Returns
string — The body bytes.
isStreaming()#source
function isStreaming(): bool
Whether the body is produced by a closure set with stream().
Returns
bool — true for a streamed body.
wantsClose()#source
function wantsClose(): bool
Whether close() was called.
Returns
bool — true if the handler asked to close the connection.
statusWasSet()#source
function statusWasSet(): bool
Whether the handler chose a status through the constructor or status(). If not, an ambient http_response_code() decides.
Returns
bool — true if a status was set explicitly.