Menu

Reference / Http

Response

finalclass
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 body

See also: Request, Server, Status

Properties

statusintThe status code, 200 by default.
headersHeadersThe header block.

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

$status int = 200
Status code; see Status for named constants.
$body string = ''
Initial body. Content-Type is not set by this; use text(), html() or type().

status()#source

function status(int $c): Response

Sets the status code.

Parameters

$c int
Status code, for example Status::NOT_FOUND.

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

$n string
Header name.
$v string
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

$n string
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

$ct string
Media type, with parameters if needed, for example application/json or text/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

$s string
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

$s string
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

$b string
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/json

write()#source

function write(string $b): Response

Appends to the body. Appending is amortized: repeated calls do not copy the body each time.

Parameters

$b string
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

$fn callable
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
gamma

See 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

$path string
Path of a regular file.
$type ?string = null
Content-Type to use; null picks one with mimeFor().

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 Found

See 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

$loc string
Target URL or path.
$code int = 302
Redirect status: 301, 302, 303, 307 or 308.

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): void that 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.

View source