Web basics

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.

Contract SendableTypes 4Shared headers · cookies

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.

main/Controller/UserController.php
#[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.

php
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;

return ResponseEntity::ok(['id' => 42, 'name' => 'Anna']);

The client receives:

text
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

php
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): static

Parameters

$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

main/Controller/OrderController.php
#[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

php
public static function status(HttpCode $code): static

Parameters

$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

php
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

php
public function body(mixed $body): static

Parameters

$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

php
$response = ResponseEntity::ok();

if ($includeDetails) {
  $response->body(['id' => 42, 'name' => 'Anna', 'email' => 'anna@example.com']);
} else {
  $response->body(['id' => 42]);
}

return $response;

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

php
public function header(string $name, string $value): static

Parameters

$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

php
return ResponseEntity::ok(['status' => 'ok'])
  ->header('X-Request-Id', $requestId)
  ->header('Cache-Control', 'no-store');

Result

text
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Request-Id: 7f3c1a
Cache-Control: no-store

{"status":"ok"}

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

php
public function cookie(SetCookie $cookie): static

Parameters

$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

php
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.

php
return ResponseEntity::ok('pong');
text
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8

pong

Reading 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

php
public function getCode(): HttpCode
public function getBody(): mixed
public function getHeaders(): array      // ['name' => 'value']
public function getCookies(): array      // list<SetCookie>

Example

main/Http/RequestIdMiddleware.php
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

php
public static function csv(
  array $rows,
  string $fileName,
  string $mimeType = 'text/csv',
  bool $isAttachment = true,
  HttpCode $httpCode = HttpCode::OK,
  int $maxAge = 0,
): static

Parameters

$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

main/Controller/ExportController.php
#[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

text
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.50

json()

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

php
public static function json(
  array|string $data,
  string $fileName,
  string $mimeType = 'application/json',
  bool $isAttachment = false,
  HttpCode $httpCode = HttpCode::OK,
  int $maxAge = 0,
): static

Parameters

$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

php
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

php
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,
): static

Parameters

$data — an array, an object, a ready SimpleXMLElement, or a scalar value.

The remaining parameters are as in json().

Example

php
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

php
public static function txt(
  mixed $data,
  string $fileName,
  string $mimeType = 'text/plain',
  bool $isAttachment = false,
  HttpCode $httpCode = HttpCode::OK,
  int $maxAge = 0,
): static

Example

php
$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

php
public static function binary(
  mixed $data,
  string $fileName,
  string $mimeType = 'application/octet-stream',
  bool $isAttachment = true,
  HttpCode $httpCode = HttpCode::OK,
  int $maxAge = 0,
): static

Example

php
$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

php
public static function file(
  string $filePath,
  bool $isAttachment = false,
  HttpCode $httpCode = HttpCode::OK,
  int $maxAge = 0,
): static

Parameters

$filePath — an absolute path to the file.

Example

php
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

php
public function attachment(): static
public function inline(): static

Returns

The same response object.

Example

php
$response = ResponseFile::binary($pdf, 'invoice-1001.pdf', 'application/pdf');

return $download
  ? $response->attachment()   // a "Save as…" dialog
  : $response->inline();      // open in the browser's viewer

maxAge()

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

php
public function maxAge(int $seconds): static

Parameters

$seconds — how long the copy may be kept.

Example

php
return ResponseFile::binary($avatar, 'avatar.webp', 'image/webp')
  ->maxAge(60 * 60 * 24 * 30);   // a month

Result

text
Cache-Control: public, max-age=2592000, must-revalidate

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 HEAD handling — 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

php
public static function open(
  string $filePath,
  bool $isAttachment = false,
  HttpCode $httpCode = HttpCode::OK,
  int $maxAge = 0,
): self

Parameters

$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

main/Controller/MediaController.php
#[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

php
public function fileName(string $name): static

Parameters

$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

php
// 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

php
public function contentType(string $mime): static

Parameters

$mime — the content type, for instance application/pdf or video/mp4.

Returns

The same response object.

Example

php
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

php
public function acceptRanges(bool $enabled = true): static

Parameters

$enabled — false turns partial delivery off.

Returns

The same response object.

Example

php
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

php
public function beforeSend(?Closure $hook): static

Parameters

$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

php
$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.

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

php
public static function view(
  string $resourceName,
  array $data = [],
  HttpCode $httpCode = HttpCode::OK,
): static

Parameters

$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

php
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

php
public static function render(
  string $templateName,
  string $resourceName,
  array $data = [],
  HttpCode $httpCode = HttpCode::OK,
): static

Parameters

$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

php
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:

php
public function sniffable(bool $allow = true): static

There 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:

text
Content-Disposition: attachment; filename="_______ No14.pdf"; filename*=UTF-8''%D0%94%D0%BE%D0%B3%D0%BE%D0%B2%D0%BE%D1%80%20No14.pdf

A 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

php
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

main/Http/EventStreamResponse.php
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:

php
#[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