Responses
A controller in this framework does not write to the socket and does not call
echo. It returns an object describing the response to come, and
turning that object into correct HTTP is the router’s job.
What a response is, and why it is an object
An HTTP response has three parts: a status code (a number such as 200 or 404),
headers (name–value pairs describing the response) and a body (the data itself).
On top of that come rules browsers and intermediate servers expect to be honoured: the
declared length must match what was actually sent, a 304 must carry no body, a request
for part of a file must be answered with 206 rather than 200.
The problem. When every controller assembles all of that by hand, the rules spread
out across the project. A Content-Length missing here, a 304 with a body there, a
HEAD request — the one where a client asks for headers only — downloading a whole file
for nothing. Mistakes of this kind stay invisible during development: a browser forgives
a great deal, and the failure shows up later, on a proxy or a mobile client.
The solution. The controller returns an object describing what it wants to send:
data, a file, a page. Turning that into correct HTTP is the object’s own job. Which is
why $response->end() never appears in application code.
#[GetMapping('users/{id}')]
public function show(#[PathVariable] int $id): ResponseEntity
{
$user = $this->users->findById($id);
if ($user === null) {
return ResponseEntity::notFound(['message' => 'User not found']);
}
return ResponseEntity::ok($user);
}Which type to choose
All four types implement one interface and are returned from a controller the same way. What separates them is where the bytes of the body come from.
| What you need to send | Type | Source of the bytes |
|---|---|---|
| Data for an API — JSON or XML | ResponseEntity |
memory, through serialisation |
| A file you are building right now | ResponseFile |
memory, whole |
| A file that already sits on disk | ResponseStreamFile |
disk, without loading into memory |
| An HTML page | ResponseView |
a template |
| Something not listed here | Sendable |
your own implementation |
The practical rule:
- the data is already in a variable →
ResponseEntity; - the file does not exist yet and you are creating it (a report, an export) →
ResponseFile; - the file exists on disk (a user upload, a video, an archive) →
ResponseStreamFile.
The difference between the two file types matters. ResponseFile holds the entire body in
memory: a 500 MB file means 500 MB occupied for the duration of the request.
ResponseStreamFile reads nothing into memory and, on top of that, can send part of a
file — without which video seeking and resuming an interrupted download do not work.
Returning without an object
A controller may also return a plain value — an array, a string, an object. The router
wraps it in ResponseEntity::ok() and the client receives a 200.
That is convenient while sketching an endpoint, but it leaves you no way to set another status code or a header. Past the draft stage, return the object explicitly.
Reference
ResponseEntity
ResponseEntity is the response object for data already held in the application’s
memory: an array, an object, a string, a number. It is the everyday type for REST APIs
and for anything returning data rather than files or pages.
The object holds three things — a status code, a set of headers and a body — and is assembled through chained calls. The body is serialised at send time: an array or object becomes JSON or XML depending on what the client asked for; a string or number goes out as plain text.
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
return ResponseEntity::ok(['id' => 42, 'name' => 'Anna']);The client receives:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":42,"name":"Anna"}Status factories
Eleven static methods, each creating an object with a status code chosen in advance. They
exist so you do not have to keep code numbers in your head, or write
status(HttpCode::NOT_FOUND) where notFound() will do.
Signature
public static function ok(mixed $body = null): static
public static function created(mixed $body = null): static
public static function accepted(mixed $body = null): static
public static function noContent(): static
public static function badRequest(mixed $body = null): static
public static function unauthorized(mixed $body = null): static
public static function forbidden(mixed $body = null): static
public static function notFound(mixed $body = null): static
public static function conflict(mixed $body = null): static
public static function unprocessable(mixed $body = null): static
public static function internalError(mixed $body = null): staticParameters
$body — the response body. An array or object will be serialised to JSON or XML, a
string or number goes out as text. null — which is the default — means a response with
no body. noContent() takes no argument at all: a 204 is by definition sent without a
body.
What each code means
| Method | Code | When it is used |
|---|---|---|
ok() |
200 | An ordinary successful response carrying data |
created() |
201 | A new resource was created. Customarily accompanied by a Location header naming it |
accepted() |
202 | The request was taken for processing but has no result yet — queued, for instance |
noContent() |
204 | The operation succeeded and there is nothing to say: a delete, an acknowledgement, a read receipt |
badRequest() |
400 | The request could not be parsed: malformed JSON, a missing required parameter |
unauthorized() |
401 | The client is not authenticated — no token, or an invalid one |
forbidden() |
403 | The client is authenticated but not permitted to do this |
notFound() |
404 | The requested resource does not exist |
conflict() |
409 | The action conflicts with the current state: a duplicate unique field, a simultaneous edit |
unprocessable() |
422 | The request parses but fails on meaning — the usual answer to a failed validation |
internalError() |
500 | Something went wrong on the server |
Example
#[PostMapping('orders')]
public function create(#[RequestBody] OrderForm $form): ResponseEntity
{
$order = $this->orders->create($form);
return ResponseEntity::created(['id' => $order->id])
->header('Location', '/orders/' . $order->id);
}
#[DeleteMapping('orders/{id}')]
public function delete(#[PathVariable] int $id): ResponseEntity
{
$this->orders->delete($id);
return ResponseEntity::noContent();
}Errors are easier thrown than returned
return ResponseEntity::notFound() works, but it makes you carry the result up by hand
through every level: the service returned null, the controller checked it, the
controller returned a response.
A ResponseException surfaces from anywhere — a repository, a service — and the router
turns it into the same response, with no checks along the way:
throw new ResponseException('User not found', HttpCode::NOT_FOUND);See Error handling.
status()
Creates a response object with an arbitrary status code. Needed when none of the
factories fits: the HttpCode enum holds every HTTP code, while the factories cover the
eleven most frequent.
Signature
public static function status(HttpCode $code): staticParameters
$code — a case of the HttpCode enum, for instance HttpCode::TOO_MANY_REQUESTS.
Returns
A new response object with no body. The body is set next, with body().
Example
use Flytachi\Winter\Base\HttpCode;
return ResponseEntity::status(HttpCode::TOO_MANY_REQUESTS)
->body(['message' => 'Too many requests, try again in a minute'])
->header('Retry-After', '60');body()
Sets or replaces the body of an already created object.
The status factories take the body as their first argument, so a separate body() call
is needed in two cases: when the object came from status(), and when the
body is computed later than the code is chosen.
Signature
public function body(mixed $body): staticParameters
$body — the new body. Anything previously set is replaced entirely.
Returns
The same response object, so the call can be continued in a chain.
Example
$response = ResponseEntity::ok();
if ($includeDetails) {
$response->body(['id' => 42, 'name' => 'Anna', 'email' => 'anna@example.com']);
} else {
$response->body(['id' => 42]);
}
return $response;header()
Adds a header to the response, or replaces one already added under the same name.
Headers are held in a name-to-value map, so calling the method twice with the same name
does not add a second header — it overwrites the first. For Set-Cookie, the one header
the standard allows to repeat, there is a separate method, cookie().
Signature
public function header(string $name, string $value): staticParameters
$name — the header name, such as Location or X-Request-Id.
$value — the value. Always a string: numbers and booleans have to be cast yourself.
Returns
The same response object.
Example
return ResponseEntity::ok(['status' => 'ok'])
->header('X-Request-Id', $requestId)
->header('Cache-Control', 'no-store');Result
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Request-Id: 7f3c1a
Cache-Control: no-store
{"status":"ok"}cookie()
Attaches a cookie to the response — a value the browser stores and sends back with subsequent requests.
Unlike headers, cookies are kept in a list rather than a map: the method may be called as
often as you like, and each cookie leaves in its own Set-Cookie header. The cookie
itself is described by a SetCookie object with a lifetime, a scope and security flags —
a topic of its own, covered on the Cookies page.
Signature
public function cookie(SetCookie $cookie): staticParameters
$cookie — a prepared cookie object. Built with Cookie::make() when the request scheme
and the application’s defaults should be taken into account, or with SetCookie::make()
when there is no request at hand.
Returns
The same response object.
Example
use Flytachi\Winter\Kernel\Http\Cookie\Cookie;
return ResponseEntity::ok(['name' => 'Anna'])
->cookie(Cookie::make('theme', 'dark')->expiresIn(60 * 60 * 24 * 365)->httpOnly(false))
->cookie(Cookie::make('sid', $sessionId)->expiresIn(3600));Content negotiation
When the body is an array or an object, the serialisation format is chosen from the
Accept header the browser or client library sends with the request. That header lists
the formats the client is prepared to receive.
Two formats are supported, JSON and XML. Everything else falls back to JSON.
| The client sent | The response will be |
|---|---|
Accept: application/xml |
application/xml; charset=utf-8 |
Accept: application/json |
application/json; charset=utf-8 |
Accept: text/html |
application/json; charset=utf-8 |
| no header | application/json; charset=utf-8 |
Two points deserve their own explanation.
Why text/html yields JSON. A browser with an API address typed into its bar asks for
HTML — because it always asks for HTML. But an endpoint returning an array has no HTML to
give. Dumping the structure into markup would deliver neither the data nor a page. When a
page really is wanted, that is ResponseView.
A scalar skips negotiation. A string, number or boolean goes out as
text/plain; charset=utf-8 regardless of Accept: there is nothing here to serialise.
return ResponseEntity::ok('pong');HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
pongReading a built response
Four methods for inspecting what an object already holds. Application code rarely needs them — they are for middleware extending a controller’s response, and for tests checking the result without starting a server.
Signature
public function getCode(): HttpCode
public function getBody(): mixed
public function getHeaders(): array // ['name' => 'value']
public function getCookies(): array // list<SetCookie>Example
public function after(mixed $result): mixed
{
if ($result instanceof ResponseEntity && $result->getCode() === HttpCode::OK) {
$result->header('X-Served-By', gethostname());
}
return $result;
}ResponseFile
ResponseFile is the response object for a file the application builds during the
request itself: an export of orders as CSV, a report as JSON, a dump of settings. No such
file exists on disk and none will appear — the body is assembled in memory from the data
you pass and sent to the client.
That brings the main limitation: the entire body is held in memory. For an export of
a few megabytes that is fine; for a file of hundreds of megabytes it is not — such a file
should be written to disk and served with
ResponseStreamFile.
The class sets the headers by which a browser decides what to do with the response:
Content-Type (which format), Content-Disposition (download or display),
Content-Length (how many bytes), Cache-Control (how long to keep a copy).
csv()
Builds a CSV file from an array of rows. Each element of the array becomes a line, each element of a nested array a cell; values are escaped according to CSV rules, so commas and quotes inside the data do not break the file.
Signature
public static function csv(
array $rows,
string $fileName,
string $mimeType = 'text/csv',
bool $isAttachment = true,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticParameters
$rows — the lines of the file to be. Usually an array of arrays, where the first entry
holds the column headings.
$fileName — the name the file will be offered under. Include the extension: it is used
as given.
$mimeType — the content type. Rarely worth changing, though
text/csv; charset=utf-8 helps with strict clients.
$isAttachment — true (the default) makes the browser show a save dialog; false asks
it to open the content in the tab.
$httpCode — the response status code.
$maxAge — how many seconds the client may keep a copy. Zero means the copy has to be
revalidated on every request.
Example
#[GetMapping('orders/export')]
public function export(): ResponseFile
{
$rows = [
['Number', 'Date', 'Total'],
['A-1001', '2026-08-01', '15400.00'],
['A-1002', '2026-08-02', '9800.50'],
];
return ResponseFile::csv($rows, 'orders.csv');
}Result
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename="orders.csv"; filename*=UTF-8''orders.csv
Content-Length: 71
Number,Date,Total
A-1001,2026-08-01,15400.00
A-1002,2026-08-02,9800.50json()
Builds a file with JSON content. It differs from ResponseEntity in that the result is
offered as a file rather than as the body of an API response: it has a name and,
optionally, a save dialog.
Signature
public static function json(
array|string $data,
string $fileName,
string $mimeType = 'application/json',
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticParameters
$data — an array to be encoded as JSON, or a JSON string that is already prepared.
$fileName — the file name for the client.
The remaining parameters match csv(), except that $isAttachment defaults to
false: JSON is looked at in the browser more often than it is saved.
Example
return ResponseFile::json([
'exportedAt' => '2026-08-21T10:00:00Z',
'orders' => [['id' => 1001, 'total' => 15400], ['id' => 1002, 'total' => 9800]],
], 'orders.json');xml()
Builds a file with XML content. An array becomes a tree of elements whose keys turn into tag names.
Signature
public static function xml(
SimpleXMLElement|stdClass|array|string|int|bool $data,
string $fileName,
string $mimeType = 'application/xml',
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticParameters
$data — an array, an object, a ready SimpleXMLElement, or a scalar value.
The remaining parameters are as in json().
Example
return ResponseFile::xml([
'order' => ['id' => 1001, 'total' => 15400],
], 'order-1001.xml');txt()
Builds a text file from an arbitrary value. The value is cast to a string, so it suits both prepared text and numbers.
Signature
public static function txt(
mixed $data,
string $fileName,
string $mimeType = 'text/plain',
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticExample
$report = "Orders today: 42\nCancelled: 3\nRevenue: 415,200\n";
return ResponseFile::txt($report, 'summary.txt');binary()
Sends arbitrary binary data as a file: a generated image, a PDF, an archive assembled in memory.
It is the only one of these methods without a meaningful default content type —
application/octet-stream means “just bytes”, and a browser always offers to save such a
response. Pass the real type as the third argument when you know it.
Signature
public static function binary(
mixed $data,
string $fileName,
string $mimeType = 'application/octet-stream',
bool $isAttachment = true,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticExample
$png = $this->qrCodeGenerator->render('https://example.com/order/1001');
return ResponseFile::binary($png, 'qr-1001.png', 'image/png', isAttachment: false)
->maxAge(86400);file()
Reads an existing file from disk entirely into memory and sends it.
The method exists for small files that need this class’s behaviour — replacing the name
offered on download, for instance. For everything else use
ResponseStreamFile::open(): it spends no memory on the file’s size and supports
partial delivery.
Signature
public static function file(
string $filePath,
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticParameters
$filePath — an absolute path to the file.
Example
return ResponseFile::file('/var/www/app/resources/terms.pdf');attachment() and inline()
Switch what the browser does: download the file, or display it in the tab. Technically
they change the Content-Disposition header — attachment triggers a save dialog,
inline invites the browser to open the content if it can.
Every factory already picks a sensible default, so these methods matter where the decision follows a condition rather than the choice of factory.
Signature
public function attachment(): static
public function inline(): staticReturns
The same response object.
Example
$response = ResponseFile::binary($pdf, 'invoice-1001.pdf', 'application/pdf');
return $download
? $response->attachment() // a "Save as…" dialog
: $response->inline(); // open in the browser's viewermaxAge()
Sets how many seconds a client may keep a copy of the response without asking the server again.
The method writes the Cache-Control header. A value of 0 — the default — means the
copy must be revalidated on every request.
Signature
public function maxAge(int $seconds): staticParameters
$seconds — how long the copy may be kept.
Example
return ResponseFile::binary($avatar, 'avatar.webp', 'image/webp')
->maxAge(60 * 60 * 24 * 30); // a monthResult
Cache-Control: public, max-age=2592000, must-revalidateheader(), cookie(), sniffable()
These behave as they do on the other types. header() and cookie() are described
above; sniffable() sits in the shared section,
since it applies to both file types.
ResponseStreamFile
ResponseStreamFile is the response object for a file that already exists on disk: a
document a user uploaded, a video, an archive, a backup.
It differs from ResponseFile in two ways.
The file is never read into memory. The operating system kernel is told “send this file’s contents to the client”, and the data travels from disk to the network without passing through the process’s memory. Sending a file of several gigabytes therefore costs as much memory as sending a small one.
It is a proper file server, not just a way to send bytes. It does what browsers and download managers expect from an address holding a file:
- partial delivery — a client can ask for a slice of the file (“bytes 5000 through 9999”) and receive only that. Video seeking and resuming an interrupted download both rest on this;
- conditional requests — on a repeat visit the client asks “has this changed?” and, if it has not, receives a short bodiless answer instead of downloading again;
- correct
HEADhandling — the request in which a client asks for headers alone, to learn a file’s size and type without fetching its contents.
All of it is on from the start and needs no configuration.
open()
Creates a response object for the file at the given path.
The file’s existence is checked immediately: if it is not there, the method throws a
RuntimeException. That is deliberate — failing while the response is being built is a
better place than failing halfway through writing it, once the headers have already gone
out.
Signature
public static function open(
string $filePath,
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): selfParameters
$filePath — an absolute path to an existing file.
$isAttachment — true triggers a save dialog; false (the default) lets the browser
display the content if it can.
$httpCode — the response status code.
$maxAge — how long the client may keep a copy, in seconds.
Returns
A response object. The name shown to the client defaults to the one in the path, and the content type is detected from the file itself.
Errors
RuntimeException — when there is no file at the given path.
Example
#[GetMapping('media/{name}')]
public function show(#[PathVariable] string $name): ResponseStreamFile
{
return ResponseStreamFile::open('/var/www/app/storage/media/' . $name);
}fileName()
Sets the name the file will be offered under, when it differs from the name on disk.
Such a mismatch is common. Files uploaded by users are usually stored under generated names: it avoids collisions and keeps user input out of filesystem paths. The real name lives in the database.
Signature
public function fileName(string $name): staticParameters
$name — the name for the client. No encoding or escaping is needed: the header is
assembled correctly, including names with non-Latin characters and quotes.
Returns
The same response object.
Example
// On disk: 9f2b1c4e8a.bin — a generated name with no extension.
// The client is shown the name the file was uploaded under.
return ResponseStreamFile::open('/var/www/app/storage/uploads/9f2b1c4e8a.bin')
->fileName('Contract No. 14 of 2026-08-01.pdf')
->attachment();contentType()
States the content type explicitly instead of detecting it from the file.
Without this call the type is detected automatically: the file is opened and its opening bytes are read, and the format is recognised from them. That is reliable but not free — the measured cost on a 2 MB file is about 0.9 ms. When the application already knows the type, there is no reason to work it out again.
For frequently requested static files the method earns its place twice over. The content type is stated not only alongside the file but on the short “not modified” replies as well — otherwise a client’s cache would replace the type it stored with a wrong one. And those replies are what a popular file mostly receives: a browser that downloaded an image once only asks, on every later visit, whether it has changed. Stating the type explicitly takes the detection off all of them.
Signature
public function contentType(string $mime): staticParameters
$mime — the content type, for instance application/pdf or video/mp4.
Returns
The same response object.
Example
return ResponseStreamFile::open('/var/www/app/storage/uploads/9f2b1c4e8a.bin')
->fileName('Contract No. 14.pdf')
->contentType('application/pdf');Detection is deferred until first needed
Automatic detection happens not in open() but the first time the type is actually
wanted, and the result is remembered for the rest of the response. The file is opened for
it once, not once per header.
acceptRanges()
Turns partial delivery of the file on or off.
Partial delivery is a client’s ability to ask for a slice of a file rather than all of it. A browser uses it when the user seeks to the middle of a video, a download manager when it resumes an interrupted transfer. It is on by default.
Turn it off where the response has to be indivisible: a one-shot link, a paid download that decrements a balance, a download count. With it off, the server tells the client it supports no ranges and ignores any slice request, sending the whole file.
Signature
public function acceptRanges(bool $enabled = true): staticParameters
$enabled — false turns partial delivery off.
Returns
The same response object.
Example
return ResponseStreamFile::open('/var/www/app/storage/reports/2026-08.pdf')
->acceptRanges(false)
->attachment();beforeSend()
Registers a function to be called immediately before the file’s contents are sent.
The method exists for actions that must happen exactly when the file really goes out: incrementing a download counter, spending a one-shot link, charging a quota. This cannot be written in the controller: the controller does not know how the response will end — with the file, with a short “not modified”, or with a refusal.
Signature
public function beforeSend(?Closure $hook): staticParameters
$hook — a function taking one argument, int $bytes — the number of bytes the
Content-Length header will announce. On a partial delivery that is the size of the
slice, not of the whole file. null removes a previously registered function.
Returns
The same response object.
When it is called
| What happens | Function called |
|---|---|
| The whole file is sent | yes, with the file size |
| The requested slice is sent | yes, with the slice size |
| The file has not changed, no body is sent | no |
| The requested slice lies outside the file, refused | no |
A HEAD request arrived — headers only |
no |
An exception thrown from the function cancels delivery entirely. That is deliberate: if the download could not be recorded, the file should not go out.
Example
$downloads = $this->downloads; // a counting service injected into the controller
return ResponseStreamFile::open('/var/www/app/storage/books/php-8.pdf')
->acceptRanges(false)
->attachment()
->beforeSend(function (int $bytes) use ($downloads): void {
$downloads->register(bookId: 14, bytes: $bytes);
});This is an intent to send, not a confirmed delivery
The function runs before the transfer begins. The file then leaves through the operating system, and if the client drops the connection halfway the application never learns of it — no such feedback exists at this level.
So a counter built on beforeSend() counts downloads started, not finished. For a
download count the difference rarely matters; for billing traffic it does, and it is
better known in advance.
attachment(), inline(), maxAge(), header(), cookie(), sniffable()
These match the methods of the same names on ResponseFile — they are
declared in the base both file types share.
What happens without your involvement
Below is behaviour the class provides on its own. Knowing it helps in understanding what the client sees, but nothing here needs configuring.
| The client’s request | The response |
|---|---|
An ordinary GET |
200, the whole file, plus markers of its version |
A repeat GET, the file has not changed |
304 with no body — the client uses its own copy |
A GET for a slice that exists |
206 and only the requested bytes |
A GET for a slice beyond the file |
416 — a refusal stating the real size |
A GET with a malformed slice request |
200, the whole file: a malformed request is ignored |
A GET for several slices |
200, the whole file |
A HEAD |
the same headers as a GET, without the body |
Markers of the file’s version. Every response carries two headers: ETag (a short
signature that changes when the file does) and Last-Modified (the time of the last
change). On the next visit the client sends them back, asking “has it changed?”. If it
has not, it receives a 304 — a response with no body — and shows the file from its own
cache. Nothing is downloaded.
What travels with a bodiless response. On the short replies — “not modified” and a refused range — the client receives not only the version markers but the caching policy, your own headers, the cookies and the content type. The reason lies in how caches work: on receiving such a reply, a cache replaces the fields it stored with the ones that arrived. Not sending the caching policy leaves the client on the previous one even after you changed it; not sending the type lets the stored type be replaced by a wrong one.
Freshness of the file’s details. Before every response the file’s size and modification time are read from the filesystem afresh. Without that, a server running for hours could announce the old size for a file another process had replaced, and the response would arrive truncated.
A file that vanished. If the file is deleted between the response being built and
being sent, delivery stops with a RuntimeException. The alternative — an empty 200
which the client would then cache — is considerably worse.
ResponseView
ResponseView is the response object for an HTML page assembled from a template. It is
used where the server returns markup rather than data: server-side rendering, emails,
admin interfaces.
Templates, layouts and helper functions are covered in detail on Views. Here is only where this type sits among the others.
view()
Renders a single template with no wrapper around it.
Signature
public static function view(
string $resourceName,
array $data = [],
HttpCode $httpCode = HttpCode::OK,
): staticParameters
$resourceName — the path to the template relative to the views directory, without an
extension.
$data — data available inside the template under the names of its keys.
$httpCode — the status code.
Example
return ResponseView::view('errors/404', ['path' => '/orders/999'], HttpCode::NOT_FOUND);render()
Renders a template nested inside a page layout — the one declaring <head>, the header
and the footer.
Signature
public static function render(
string $templateName,
string $resourceName,
array $data = [],
HttpCode $httpCode = HttpCode::OK,
): staticParameters
$templateName — the path to the layout.
$resourceName — the path to the template placed inside that layout.
$data — data available to both the layout and the template.
$httpCode — the status code.
Example
return ResponseView::render('layouts/main', 'orders/index', [
'title' => 'Orders',
'orders' => [['id' => 1001, 'total' => 15400]],
]);Shared by every type
The X-Content-Type-Options header
Both file types — ResponseFile and ResponseStreamFile — add
X-Content-Type-Options: nosniff to the response.
It forbids the browser to guess the content type from the bytes themselves instead of
trusting the declared Content-Type. Guessing is dangerous when a user uploaded the
file: an innocent-looking text file containing markup may be shown as HTML — and the
script inside it executed. Both classes always state the type explicitly, so guessing is
of no use to them in any scenario.
It is switched off with sniffable(), if the content is something you produced and you
genuinely want the browser to decide:
public function sniffable(bool $allow = true): staticThere is no inverse operation: a header can be overwritten with another value but not removed, which is why the switch is a method of its own.
The file name in the header
The name a file is offered under travels in the Content-Disposition header. That
header’s format does not allow an arbitrary string to be dropped in: a quote inside the
name would end the value early, and characters outside the Latin alphabet are not
permitted in headers at all.
So the name is sent twice — in a simplified form for old clients, and encoded for modern ones:
Content-Disposition: attachment; filename="_______ No14.pdf"; filename*=UTF-8''%D0%94%D0%BE%D0%B3%D0%BE%D0%B2%D0%BE%D1%80%20No14.pdfA modern browser takes the second form and saves the file under its real name. An older
client takes the first and saves it with underscores in place of the non-Latin
characters. Nothing needs doing for this — the header is assembled automatically from the
name given to fileName() or taken from the path.
HEAD requests
HEAD is the request in which a client asks for the response headers only, without a
body. It is how a file’s size is learned before downloading, or a resource is checked for
existence.
No separate handler is needed: the framework runs the same controller method as for
GET, assembles the response in full and drops the body before sending. The headers match
what an ordinary request would have produced, Content-Length included.
Side effects do run on HEAD
Since the controller runs in full, any side effect in it happens on HEAD too. A download
counter incremented directly in the controller method will count a request in which the
client received not one byte of content.
For files that trap is closed: beforeSend() is not called on HEAD.
Sendable
Sendable is the interface all four response types implement. When a controller returns
an object implementing it, the router sends that object directly, wrapping it in nothing.
Your own implementation is called for when the protocol does not fit the ready-made
types: a stream of events, a custom binary format, a response with unusual caching
semantics. For “the same JSON but with a couple of headers”, ResponseEntity is enough.
Interface
interface Sendable
{
public function send(HttpResponse $response, HttpRequest $request): void;
}Parameters of the method
$response — the object the response is written into: status(), header(),
cookie(), end(), sendfile().
$request — the incoming request. It is passed because many responses need it: to choose
a format from Accept, to read a requested range, or to honour conditional headers.
Example
use Flytachi\Winter\Kernel\Http\Contracts\{HttpRequest, HttpResponse};
use Flytachi\Winter\Kernel\Http\Response\Sendable;
final class EventStreamResponse implements Sendable
{
/** @param list<array{event: string, data: array}> $events */
public function __construct(private array $events)
{
}
public function send(HttpResponse $response, HttpRequest $request): void
{
$response->status(200);
$response->header('Content-Type', 'text/event-stream');
$response->header('Cache-Control', 'no-cache');
$body = '';
foreach ($this->events as $event) {
$body .= "event: {$event['event']}\n";
$body .= 'data: ' . json_encode($event['data']) . "\n\n";
}
$response->end($body);
}
}Using it looks no different from the built-in types:
#[GetMapping('events')]
public function stream(): EventStreamResponse
{
return new EventStreamResponse([
['event' => 'order.created', 'data' => ['id' => 1001]],
['event' => 'order.paid', 'data' => ['id' => 1001]],
]);
}Next
- Cookies — the
cookie()method in detail - Views — templates and layouts
- Error handling — exceptions instead of returning an error code
- Controllers — where these objects are returned