Requests & Responses
Every handler in Fast works with two things: the incoming request and the response it sends back. Both are small, predictable objects, and you will reach for them constantly.
Reading the request
Inside any handler you can grab the current request with the request() helper. It is aware of the current fiber, so concurrent requests never see each other's data. Here is the tour:
$r = request();
$r->method; // "GET"
$r->path; // "/user/42"
$r->query('page', '1'); // a query-string value, with a default
$r->header('accept'); // a header, matched case-insensitively
$r->json(); // the decoded JSON body, or null
$r->form(); // the parsed form body (urlencoded or multipart)
$r->file('avatar'); // a single uploaded file, or null
$r->files('photos'); // every uploaded file for a repeated field
$r->attribute('id'); // a route parameter
A couple of things worth knowing. query() and the header lookup both accept a default, so you do not have to null-check everywhere. json() returns null when the body is not valid JSON, rather than throwing.
A note on request bodies
request()->form() parses application/x-www-form-urlencoded bodies and the text fields of a multipart/form-data body. If you are receiving JSON, use json(). For any other media type, read the raw body and parse it yourself.
Fast keeps a strict default: request bodies need a valid Content-Length, cannot exceed 8 MiB, and cannot use Transfer-Encoding. A route that genuinely needs a larger or chunked body can opt in with BodyPolicy:
use Fast\Http\BodyPolicy;
$gitBodies = new BodyPolicy(
maxBytes: 8 * 1024 * 1024 * 1024,
allowChunked: true,
spoolDirectory: __DIR__ . '/var/bodies',
spoolThreshold: 1024 * 1024,
);
serve(
bodyTimeout: 1800.0,
bodyPolicy: static fn (string $method, string $rawPath): ?BodyPolicy =>
$method === 'POST' && $rawPath === '/git-receive-pack'
? $gitBodies
: null,
);
The resolver sees the uppercase method and raw path before header validation. Returning null preserves every strict default. Even with a policy, Fast rejects duplicate framing headers, Transfer-Encoding together with Content-Length, unsupported transfer codings, oversized decoded bodies, and malformed chunks.
bodyTimeout remains one absolute deadline for the complete body, regardless of progress. Size it from the largest allowed body and minimum upload speed you accept, while keeping a finite bound against slow clients.
When a body crosses spoolThreshold, Fast moves it to a private 0600 file so worker memory stays bounded. Read either form through the same API:
$stream = request()->bodyStream();
while (!feof($stream)) {
$chunk = fread($stream, 65536);
// Process $chunk.
}
fclose($stream);
bodySize() reports the total bytes and isSpooled() tells you whether disk was used. For a spooled request, the string body and the json()/form()/upload helpers stay empty rather than loading the large body back into memory. Parse the stream yourself, and send CSRF tokens in X-CSRF-Token instead of a form field.
File uploads
A multipart/form-data request carries uploaded files alongside its text fields. Reach a single file with file() and a repeated file input with files():
post('/avatar', function () {
$file = request()->file('avatar');
if ($file === null) {
return text('No file uploaded', 422);
}
$file->clientName(); // the browser's filename, e.g. "cat.png" (untrusted)
$file->clientType(); // the reported MIME type (untrusted)
$file->size(); // the size in bytes
$file->bytes(); // the raw contents
$file->store(__DIR__ . '/uploads/' . uniqid() . '.png');
return redirect('/');
});
store() writes to the exact path you give it and never derives a destination from the client's filename, so a crafted name like ../../etc/passwd cannot escape your chosen directory. Treat clientName() and clientType() as display labels only; validate the bytes yourself if the file type matters.
A multiple-file input returns a list, in the order the browser sent them:
foreach (request()->files('photos') as $photo) {
$photo->store(__DIR__ . '/gallery/' . uniqid());
}
By default, uploads live in memory as a slice of the buffered body, so the whole request is capped at 8 MiB. To validate one, merge files and fields with request()->all() and reach for the file, ext, and max_kb rules (see Validation); just remember ext trusts the client-supplied name, so sniff bytes() yourself when the real type matters. Large spooled multipart bodies need application-owned streaming parsing.
Building responses
You have two equivalent ways to build a response: the global helper functions, or the static methods on Response. Use whichever reads better to you.
// Global helpers
return html('<h1>hi</h1>');
return text('plain text');
return json(['ok' => true]);
return redirect('/login');
// The equivalent Response methods
return Response::html('<h1>hi</h1>');
return Response::text('plain text');
return Response::json(['ok' => true]);
return Response::redirect('/login');
Remember the return-value shortcut, too: if you just return a string, Fast wraps it in an HTML response, and if you return an array it becomes JSON. The explicit helpers are for when you want to set a status or headers.
Redirecting back and downloading files
Two more helpers cover common cases. back() redirects to the page the request came from (its Referer), which is handy after handling a form:
return back(); // to the Referer, or '/' when there isn't one
return back('/dashboard'); // custom fallback
As an open-redirect guard, back() only honours a Referer pointing at the same host as the current request; a cross-origin Referer is ignored in favour of the fallback.
download() streams a file from disk as an attachment (the browser saves it rather than displaying it inline):
return download('/storage/invoice.pdf');
return download($path, 'invoice-2024.pdf'); // override the download filename
The filename is sanitised against header injection, and a missing file throws rather than sending an empty response.
Status codes and headers
Responses are immutable. Methods like withStatus() and withHeader() return a new response rather than mutating the old one, so you chain them:
return json($data)->withStatus(201)->withHeader('X-Id', '7');
Header values are validated the moment you set them. If you try to sneak a CR or LF into a header value, Fast throws an InvalidArgumentException right where you built the response, instead of letting a malformed header reach the wire. Interior spaces are fine, so something like Cache-Control: public, max-age=0 works exactly as you would expect.
Returning specific error statuses
When you need to bail out with a particular status code, throw an HttpException from anywhere in your code and the kernel turns it into a proper response instead of a generic 500:
use Fast\Http\HttpException;
if ($missing) {
throw new HttpException(404, 'Not found');
}
You can pass an optional payload and headers to the exception too, which is handy for APIs that want a structured error body.
Now that you can read requests and shape responses, the last core piece is serving plain files, which we cover next in Static Files.