Ответы
Контроллер во фреймворке не пишет в сокет и не вызывает echo. Он
возвращает объект, описывающий будущий ответ, а превращением этого объекта в
корректный HTTP занимается роутер.
Что такое ответ и зачем он объектом
HTTP-ответ состоит из трёх частей: код состояния (число вроде 200 или 404),
заголовки (пары «имя: значение», описывающие ответ) и тело (собственно данные).
Кроме этого есть правила, которые браузер и промежуточные серверы ожидают увидеть
соблюдёнными: длина тела должна совпадать с реально переданной, ответ 304 обязан быть
без тела, запрос части файла должен получить код 206, а не 200.
Проблема. Если каждый контроллер собирает всё это сам, правила расползаются по
проекту. Где-то забыт Content-Length, где-то 304 уходит с телом, где-то запрос
HEAD — тот, в котором клиент просит только заголовки, — скачивает весь файл впустую.
Ошибки такого рода не видны в разработке: браузер прощает многое, а падает потом на
прокси или на мобильном клиенте.
Решение. Контроллер возвращает объект, который описывает что он хочет отдать:
данные, файл, страницу. Как это станет правильным HTTP — задача самого объекта. Именно
поэтому $response->end() в прикладном коде не встречается.
#[GetMapping('users/{id}')]
public function show(#[PathVariable] int $id): ResponseEntity
{
$user = $this->users->findById($id);
if ($user === null) {
return ResponseEntity::notFound(['message' => 'Пользователь не найден']);
}
return ResponseEntity::ok($user);
}Какой тип выбрать
Все четыре типа реализуют один интерфейс и возвращаются из контроллера одинаково. Различаются они тем, откуда берутся байты тела.
| Что нужно отдать | Тип | Откуда байты |
|---|---|---|
| Данные для API — JSON или XML | ResponseEntity |
из памяти, сериализацией |
| Файл, который вы формируете прямо сейчас | ResponseFile |
из памяти, целиком |
| Файл, который уже лежит на диске | ResponseStreamFile |
с диска, не загружая в память |
| HTML-страницу | ResponseView |
из шаблона |
| Что-то, чего здесь нет | Sendable |
ваша реализация |
Практическое правило:
- данные уже в переменной →
ResponseEntity; - файла ещё нет, вы его создаёте (отчёт, выгрузка) →
ResponseFile; - файл существует на диске (загрузка пользователя, видео, архив) →
ResponseStreamFile.
Разница между двумя файловыми типами существенна. ResponseFile держит всё тело в
оперативной памяти: файл на 500 МБ означает 500 МБ, занятых на время запроса.
ResponseStreamFile в память ничего не читает и, кроме того, умеет отдавать часть
файла — без этого не работает перемотка видео и докачка прерванной загрузки.
Возврат без объекта
Из контроллера можно вернуть и обычное значение — массив, строку, объект. Роутер сам
завернёт его в ResponseEntity::ok(), и клиент получит код 200.
Так удобно набрасывать эндпоинт, но у вас не остаётся возможности задать другой код состояния или заголовок. За пределами черновика лучше возвращать объект явно.
Справочник
ResponseEntity
ResponseEntity — объект ответа для данных, которые уже находятся в памяти приложения:
массив, объект, строка, число. Это основной тип для REST API и вообще для всего, что
отдаёт данные, а не файлы или страницы.
Объект хранит три вещи — код состояния, набор заголовков и тело — и собирается цепочкой вызовов. Тело сериализуется в момент отправки: массив или объект превращаются в JSON либо XML в зависимости от того, что попросил клиент; строка или число уходят как обычный текст.
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
return ResponseEntity::ok(['id' => 42, 'name' => 'Анна']);Клиент получит:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":42,"name":"Анна"}Фабрики состояния
Одиннадцать статических методов, каждый из которых создаёт объект с заранее выбранным
кодом состояния. Нужны для того, чтобы не держать номера кодов в голове и не писать
status(HttpCode::NOT_FOUND) там, где достаточно notFound().
Синтаксис
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Параметры
$body — тело ответа. Массив или объект будут сериализованы в JSON либо XML, строка и
число уйдут текстом. Значение null (оно же по умолчанию) означает ответ без тела.
Метод noContent() аргумента не принимает вовсе: код 204 по определению отдаётся без
тела.
Какой код что означает
| Метод | Код | Когда применяется |
|---|---|---|
ok() |
200 | Обычный успешный ответ с данными |
created() |
201 | Создан новый ресурс. Принято дополнительно отдавать заголовок Location с адресом созданного |
accepted() |
202 | Запрос принят в обработку, но результата ещё нет — например, поставлен в очередь |
noContent() |
204 | Операция удалась, но отвечать нечем: удаление, подтверждение, отметка о прочтении |
badRequest() |
400 | Запрос не удалось разобрать: битый JSON, отсутствует обязательный параметр |
unauthorized() |
401 | Клиент не аутентифицирован — токена нет или он недействителен |
forbidden() |
403 | Клиент аутентифицирован, но прав на это действие у него нет |
notFound() |
404 | Запрошенного ресурса не существует |
conflict() |
409 | Действие конфликтует с текущим состоянием: дубликат уникального поля, одновременное редактирование |
unprocessable() |
422 | Запрос синтаксически верен, но не проходит проверку по смыслу — типичный ответ на провал валидации |
internalError() |
500 | Ошибка на стороне сервера |
Пример
#[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();
}Ошибки удобнее бросать, чем возвращать
return ResponseEntity::notFound() работает, но заставляет вручную прокидывать результат
через все уровни: сервис вернул null, контроллер это проверил, вернул ответ.
Исключение ResponseException всплывает откуда угодно — из репозитория, из сервиса — и
превращается роутером в такой же ответ, без единой проверки по дороге:
throw new ResponseException('Пользователь не найден', HttpCode::NOT_FOUND);Подробнее — на странице Обработка ошибок.
status()
Создаёт объект ответа с произвольным кодом состояния. Нужен, когда среди готовых фабрик
подходящей нет: перечисление HttpCode содержит все коды HTTP, а фабрики покрывают
только одиннадцать самых частых.
Синтаксис
public static function status(HttpCode $code): staticПараметры
$code — вариант перечисления HttpCode, например HttpCode::TOO_MANY_REQUESTS.
Возвращает
Новый объект ответа без тела. Тело задаётся следом вызовом body().
Пример
use Flytachi\Winter\Base\HttpCode;
return ResponseEntity::status(HttpCode::TOO_MANY_REQUESTS)
->body(['message' => 'Слишком много запросов, попробуйте через минуту'])
->header('Retry-After', '60');body()
Задаёт или заменяет тело ответа у уже созданного объекта.
Фабрики состояния принимают тело первым аргументом, поэтому отдельный вызов body()
нужен в двух случаях: когда объект создан через status(), и когда тело
вычисляется позже, чем выбирается код.
Синтаксис
public function body(mixed $body): staticПараметры
$body — новое тело. Прежнее, если оно было, замещается целиком.
Возвращает
Тот же объект ответа, поэтому вызов можно продолжать цепочкой.
Пример
$response = ResponseEntity::ok();
if ($includeDetails) {
$response->body(['id' => 42, 'name' => 'Анна', 'email' => 'anna@example.com']);
} else {
$response->body(['id' => 42]);
}
return $response;header()
Добавляет заголовок к ответу либо заменяет уже добавленный с тем же именем.
Заголовки хранятся картой «имя → значение», поэтому повторный вызов с тем же именем не
добавляет второй заголовок, а перезаписывает первый. Для Set-Cookie — единственного
заголовка, который по стандарту может повторяться, — предусмотрен отдельный метод
cookie().
Синтаксис
public function header(string $name, string $value): staticПараметры
$name — имя заголовка, например Location или X-Request-Id.
$value — значение. Всегда строка: числа и логические значения нужно приводить самим.
Возвращает
Тот же объект ответа.
Пример
return ResponseEntity::ok(['status' => 'ok'])
->header('X-Request-Id', $requestId)
->header('Cache-Control', 'no-store');Результат
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Request-Id: 7f3c1a
Cache-Control: no-store
{"status":"ok"}cookie()
Прикрепляет к ответу куку — значение, которое браузер сохранит и будет присылать обратно при следующих запросах.
В отличие от заголовков, куки хранятся списком, а не картой: вызывать метод можно сколько
угодно раз, и каждая кука уедет своим заголовком Set-Cookie. Сама кука описывается
объектом SetCookie, у которого есть срок жизни, область видимости и флаги
безопасности — это отдельная тема, разобранная на странице
Куки.
Синтаксис
public function cookie(SetCookie $cookie): staticПараметры
$cookie — готовый объект куки. Создаётся через Cookie::make(), если нужно учесть
схему запроса и настройки приложения, либо через SetCookie::make() для случая, когда
запроса под рукой нет.
Возвращает
Тот же объект ответа.
Пример
use Flytachi\Winter\Kernel\Http\Cookie\Cookie;
return ResponseEntity::ok(['name' => 'Анна'])
->cookie(Cookie::make('theme', 'dark')->expiresIn(60 * 60 * 24 * 365)->httpOnly(false))
->cookie(Cookie::make('sid', $sessionId)->expiresIn(3600));Согласование формата
Если тело — массив или объект, формат сериализации выбирается по заголовку Accept,
который браузер или клиентская библиотека присылает вместе с запросом. Заголовок
перечисляет форматы, которые клиент готов принять.
Поддерживаются два формата: JSON и XML. Всё остальное сводится к JSON.
| Клиент прислал | Ответ будет |
|---|---|
Accept: application/xml |
application/xml; charset=utf-8 |
Accept: application/json |
application/json; charset=utf-8 |
Accept: text/html |
application/json; charset=utf-8 |
| заголовка нет | application/json; charset=utf-8 |
Два момента стоит объяснить отдельно.
Почему text/html даёт JSON. Браузер, открывший адрес API в адресной строке, просит
HTML — потому что он всегда просит HTML. Но эндпоинт, возвращающий массив, HTML не имеет.
Вывести структуру в разметку значило бы отдать ни данные, ни страницу. Если нужна
именно страница — это ResponseView.
Скаляр согласование не проходит. Строка, число или логическое значение уходят как
text/plain; charset=utf-8 независимо от Accept: сериализовать здесь нечего.
return ResponseEntity::ok('pong');HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
pongЧтение собранного ответа
Четыре метода, позволяющие посмотреть, что уже собрано в объекте. Прикладному коду они обычно не нужны — их используют middleware, которое дополняет ответ контроллера, и тесты, проверяющие результат без запуска сервера.
Синтаксис
public function getCode(): HttpCode
public function getBody(): mixed
public function getHeaders(): array // ['имя' => 'значение']
public function getCookies(): array // list<SetCookie>Пример
public function after(mixed $result): mixed
{
if ($result instanceof ResponseEntity && $result->getCode() === HttpCode::OK) {
$result->header('X-Served-By', gethostname());
}
return $result;
}ResponseFile
ResponseFile — объект ответа для файла, который приложение формирует прямо в момент
запроса: выгрузка заказов в CSV, отчёт в JSON, дамп настроек. Файла на диске не
существует и не появится — тело собирается в оперативной памяти из переданных данных и
уходит клиенту.
Из этого следует главное ограничение: всё тело держится в памяти целиком. Для
выгрузки на несколько мегабайт это нормально, для файла на сотни мегабайт — нет; такое
нужно писать на диск и отдавать через
ResponseStreamFile.
Класс сам проставляет заголовки, по которым браузер понимает, что делать с ответом:
Content-Type (что за формат), Content-Disposition (скачать или показать),
Content-Length (сколько байт), Cache-Control (сколько хранить копию).
csv()
Собирает CSV-файл из массива строк. Каждый элемент массива становится строкой файла, каждый элемент вложенного массива — ячейкой; значения экранируются по правилам CSV, так что запятые и кавычки внутри данных файл не ломают.
Синтаксис
public static function csv(
array $rows,
string $fileName,
string $mimeType = 'text/csv',
bool $isAttachment = true,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticПараметры
$rows — массив строк будущего файла. Обычно это массив массивов, где первая строка —
заголовки столбцов.
$fileName — имя, под которым файл предложат сохранить. Расширение указывать нужно:
оно попадёт в имя как есть.
$mimeType — тип содержимого. Менять имеет смысл редко, например на
text/csv; charset=utf-8 для строгих клиентов.
$isAttachment — true (по умолчанию) заставляет браузер показать диалог сохранения;
false — попытаться открыть содержимое прямо во вкладке.
$httpCode — код состояния ответа.
$maxAge — сколько секунд клиенту разрешено хранить копию. Ноль означает, что копию
нужно перепроверять при каждом обращении.
Пример
#[GetMapping('orders/export')]
public function export(): ResponseFile
{
$rows = [
['Номер', 'Дата', 'Сумма'],
['A-1001', '2026-08-01', '15400.00'],
['A-1002', '2026-08-02', '9800.50'],
];
return ResponseFile::csv($rows, 'orders.csv');
}Результат
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Disposition: attachment; filename="orders.csv"; filename*=UTF-8''orders.csv
Content-Length: 71
Номер,Дата,Сумма
A-1001,2026-08-01,15400.00
A-1002,2026-08-02,9800.50json()
Собирает файл с JSON-содержимым. Отличается от ResponseEntity тем, что результат
предлагается как файл, а не как тело API-ответа: у него есть имя и, при желании, диалог
сохранения.
Синтаксис
public static function json(
array|string $data,
string $fileName,
string $mimeType = 'application/json',
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticПараметры
$data — массив, который будет закодирован в JSON, либо уже готовая JSON-строка.
$fileName — имя файла для клиента.
Остальные параметры совпадают с csv(), но $isAttachment по умолчанию false:
JSON чаще смотрят в браузере, чем сохраняют.
Пример
return ResponseFile::json([
'exportedAt' => '2026-08-21T10:00:00Z',
'orders' => [['id' => 1001, 'total' => 15400], ['id' => 1002, 'total' => 9800]],
], 'orders.json');xml()
Собирает файл с XML-содержимым. Массив преобразуется в дерево элементов, где ключи становятся именами тегов.
Синтаксис
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Параметры
$data — массив, объект, готовый SimpleXMLElement или скалярное значение.
Остальные параметры — как у json().
Пример
return ResponseFile::xml([
'order' => ['id' => 1001, 'total' => 15400],
], 'order-1001.xml');txt()
Собирает текстовый файл из произвольного значения. Значение приводится к строке, поэтому годится как для готового текста, так и для чисел.
Синтаксис
public static function txt(
mixed $data,
string $fileName,
string $mimeType = 'text/plain',
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticПример
$report = "Заказов за день: 42\nОтменено: 3\nВыручка: 415 200\n";
return ResponseFile::txt($report, 'summary.txt');binary()
Отдаёт произвольные двоичные данные как файл: сгенерированное изображение, PDF, архив, собранный в памяти.
Единственный из методов, у которого по умолчанию нет осмысленного типа содержимого —
application/octet-stream означает «просто байты», и браузер такой ответ всегда
предлагает сохранить. Указывайте настоящий тип третьим аргументом, если он известен.
Синтаксис
public static function binary(
mixed $data,
string $fileName,
string $mimeType = 'application/octet-stream',
bool $isAttachment = true,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticПример
$png = $this->qrCodeGenerator->render('https://example.com/order/1001');
return ResponseFile::binary($png, 'qr-1001.png', 'image/png', isAttachment: false)
->maxAge(86400);file()
Читает существующий файл с диска целиком в память и отдаёт его.
Метод существует для небольших файлов, к которым нужно применить логику этого класса —
например, подменить имя при скачивании. Для всего остального используйте
ResponseStreamFile::open(): он не расходует память на размер файла и
поддерживает частичную отдачу.
Синтаксис
public static function file(
string $filePath,
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): staticПараметры
$filePath — абсолютный путь к файлу.
Пример
return ResponseFile::file('/var/www/app/resources/terms.pdf');attachment() и inline()
Переключают поведение браузера: скачать файл или показать его во вкладке. Технически они
меняют заголовок Content-Disposition — attachment вызывает диалог сохранения, inline
предлагает браузеру открыть содержимое, если он умеет.
Каждая фабрика уже задаёт разумное умолчание, поэтому эти методы нужны там, где решение принимается по условию, а не выбором фабрики.
Синтаксис
public function attachment(): static
public function inline(): staticВозвращает
Тот же объект ответа.
Пример
$response = ResponseFile::binary($pdf, 'invoice-1001.pdf', 'application/pdf');
return $download
? $response->attachment() // диалог «Сохранить как…»
: $response->inline(); // открыть в просмотрщике браузераmaxAge()
Задаёт, сколько секунд клиенту разрешено хранить копию ответа, не обращаясь к серверу повторно.
Метод выставляет заголовок Cache-Control. Значение 0 (умолчание) означает, что копию
нужно перепроверять при каждом обращении.
Синтаксис
public function maxAge(int $seconds): staticПараметры
$seconds — срок хранения копии в секундах.
Пример
return ResponseFile::binary($avatar, 'avatar.webp', 'image/webp')
->maxAge(60 * 60 * 24 * 30); // месяцРезультат
Cache-Control: public, max-age=2592000, must-revalidateheader(), cookie(), sniffable()
Работают так же, как у остальных типов. header() и cookie() описаны
выше, sniffable() — в общем разделе,
поскольку относится к обоим файловым типам.
ResponseStreamFile
ResponseStreamFile — объект ответа для файла, который уже существует на диске:
загруженный пользователем документ, видео, архив, резервная копия.
От ResponseFile отличается двумя вещами.
Файл не читается в память. Ядру операционной системы передаётся команда «отправь клиенту содержимое этого файла», и данные идут с диска в сеть, минуя память процесса. Поэтому отдать файл на несколько гигабайт стоит столько же памяти, сколько отдать маленький.
Это полноценный файловый сервер, а не просто отправка байтов. Он умеет то, чего браузеры и загрузчики ожидают от адреса, по которому лежит файл:
- частичная отдача — клиент может попросить кусок файла («байты с 5000-го по 9999-й») и получить только его. На этом держится перемотка видео и докачка прерванной загрузки;
- условные запросы — при повторном обращении клиент спрашивает «файл изменился?», и, если нет, получает короткий ответ без тела вместо повторной загрузки;
- корректная обработка
HEAD— запроса, в котором клиент просит только заголовки, чтобы узнать размер и тип, не скачивая содержимое.
Всё перечисленное включено сразу и работает без настройки.
open()
Создаёт объект ответа для файла по указанному пути.
Существование файла проверяется сразу: если файла нет, метод бросает исключение
RuntimeException. Это сделано намеренно — упасть при построении ответа лучше, чем на
середине его записи, когда клиенту уже ушли заголовки.
Синтаксис
public static function open(
string $filePath,
bool $isAttachment = false,
HttpCode $httpCode = HttpCode::OK,
int $maxAge = 0,
): selfПараметры
$filePath — абсолютный путь к существующему файлу.
$isAttachment — true вызывает диалог сохранения, false (по умолчанию) позволяет
браузеру показать содержимое, если он умеет.
$httpCode — код состояния ответа.
$maxAge — срок хранения копии у клиента в секундах.
Возвращает
Объект ответа. Имя файла для клиента по умолчанию берётся из пути, тип содержимого определяется по самому файлу.
Ошибки
RuntimeException — если по указанному пути файла нет.
Пример
#[GetMapping('media/{name}')]
public function show(#[PathVariable] string $name): ResponseStreamFile
{
return ResponseStreamFile::open('/var/www/app/storage/media/' . $name);
}fileName()
Задаёт имя, под которым файл будет предложен клиенту, когда оно не совпадает с именем на диске.
Такое расхождение — частый случай. Файлы, загруженные пользователями, обычно хранят под сгенерированными именами: так проще избежать коллизий и не хранить пользовательский ввод в путях файловой системы. Настоящее имя при этом лежит в базе данных.
Синтаксис
public function fileName(string $name): staticПараметры
$name — имя для клиента. Кодировать или экранировать его не нужно: заголовок соберётся
корректно, включая имена с кириллицей и кавычками.
Возвращает
Тот же объект ответа.
Пример
// На диске: 9f2b1c4e8a.bin — сгенерированное имя без расширения.
// Клиенту показываем то, под которым файл загружали.
return ResponseStreamFile::open('/var/www/app/storage/uploads/9f2b1c4e8a.bin')
->fileName('Договор №14 от 01.08.2026.pdf')
->attachment();contentType()
Задаёт тип содержимого явно, вместо того чтобы определять его по файлу.
Если не вызывать этот метод, тип определяется автоматически: файл открывается и у него читается начало, по которому распознаётся формат. Это надёжно, но не бесплатно — измеренная стоимость на файле в 2 МБ составляет около 0.9 мс. Когда тип уже известен приложению, эту работу незачем делать заново.
Для часто запрашиваемой статики метод особенно уместен. Тип содержимого объявляется не только вместе с файлом, но и в коротких ответах «не изменилось» — иначе кеш клиента заместил бы сохранённый тип неверным. А такие ответы у популярного файла случаются чаще всего: браузер, однажды скачавший картинку, при каждом следующем визите только переспрашивает, не изменилась ли она. Заданный явно тип снимает определение со всех этих обращений.
Синтаксис
public function contentType(string $mime): staticПараметры
$mime — тип содержимого, например application/pdf или video/mp4.
Возвращает
Тот же объект ответа.
Пример
return ResponseStreamFile::open('/var/www/app/storage/uploads/9f2b1c4e8a.bin')
->fileName('Договор №14.pdf')
->contentType('application/pdf');Определение типа отложено до первого обращения
Автоматическое определение происходит не в open(), а тогда, когда тип впервые
понадобился, и результат запоминается на время ответа. Файл ради этого открывается один
раз, а не на каждый заголовок.
acceptRanges()
Включает или выключает поддержку частичной отдачи файла.
Частичная отдача — это возможность клиента запросить кусок файла вместо всего целиком. Браузер пользуется ею, когда пользователь перематывает видео на середину, а загрузчик — когда возобновляет прерванную закачку. Включена по умолчанию.
Выключать её стоит тогда, когда ответ обязан быть неделимым: одноразовая ссылка, платная загрузка со списанием, учёт скачиваний. При выключении сервер сообщает клиенту, что диапазоны не поддерживает, и присланный запрос куска игнорирует, отдавая файл целиком.
Синтаксис
public function acceptRanges(bool $enabled = true): staticПараметры
$enabled — false выключает частичную отдачу.
Возвращает
Тот же объект ответа.
Пример
return ResponseStreamFile::open('/var/www/app/storage/reports/2026-08.pdf')
->acceptRanges(false)
->attachment();beforeSend()
Регистрирует функцию, которая будет вызвана непосредственно перед отправкой содержимого файла.
Метод нужен для действий, которые должны произойти ровно тогда, когда файл действительно уходит клиенту: увеличить счётчик скачиваний, погасить одноразовую ссылку, списать квоту. Написать это в контроллере нельзя: контроллер не знает, чем закончится ответ — отдачей файла, коротким ответом «не изменился» или отказом.
Синтаксис
public function beforeSend(?Closure $hook): staticПараметры
$hook — функция, принимающая один аргумент int $bytes — количество байт, которое
объявит заголовок Content-Length. При частичной отдаче это размер куска, а не всего
файла. Значение null снимает ранее установленную функцию.
Возвращает
Тот же объект ответа.
Когда вызывается
| Что происходит | Функция вызывается |
|---|---|
| Файл уходит целиком | да, с размером файла |
| Уходит запрошенный кусок | да, с размером куска |
| Файл не изменился, тело не передаётся | нет |
| Запрошенный кусок вне файла, отказ | нет |
Пришёл запрос HEAD — только заголовки |
нет |
Исключение, брошенное из функции, отменяет отдачу файла целиком. Это сделано намеренно: если списать скачивание не удалось, отдавать файл нельзя.
Пример
$downloads = $this->downloads; // сервис учёта, внедрён в контроллер
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);
});Это намерение отправить, а не подтверждение доставки
Функция вызывается до начала передачи. Дальше файл уходит средствами операционной системы, и если клиент оборвёт соединение на середине, приложение об этом не узнает — такой обратной связи на этом уровне попросту нет.
Значит счётчик, построенный на beforeSend(), считает начатые загрузки, а не
завершённые. Для счётчика скачиваний разница обычно несущественна, а для тарификации
трафика — существенна, и знать об этом лучше заранее.
attachment(), inline(), maxAge(), header(), cookie(), sniffable()
Совпадают с одноимёнными методами ResponseFile — они объявлены в общей
для обоих файловых типов основе.
Что происходит без вашего участия
Ниже — поведение, которое класс обеспечивает сам. Знать его полезно, чтобы понимать, что видит клиент, но настраивать ничего не нужно.
| Запрос клиента | Ответ |
|---|---|
Обычный GET |
200, файл целиком, плюс метки версии файла |
Повторный GET, файл не изменился |
304 без тела — клиент использует свою копию |
GET с запросом куска, кусок существует |
206 и только запрошенные байты |
GET с запросом куска за пределами файла |
416 — отказ с указанием реального размера |
GET с испорченным запросом куска |
200, файл целиком: испорченный запрос игнорируется |
GET с запросом нескольких кусков |
200, файл целиком |
HEAD |
те же заголовки, что у GET, но без тела |
Метки версии файла. С каждым ответом уходят два заголовка: ETag (короткая подпись,
меняющаяся при изменении файла) и Last-Modified (время последнего изменения). При
следующем обращении клиент присылает их обратно, спрашивая «изменилось ли?». Если нет —
он получает 304, ответ без тела, и показывает файл из своего кеша. Загрузки не
происходит.
Что уходит вместе с ответом без тела. На коротких ответах — «не изменилось» и отказе по диапазону — клиент получает не только метки версии, но и политику кеширования, ваши собственные заголовки, куки и тип содержимого. Причина в том, как устроены кеши: получив такой ответ, кеш замещает сохранённые у себя поля теми, что в нём пришли. Не отправить политику кеширования значит оставить клиента на прежней, даже если вы её сменили; не отправить тип — позволить заместить сохранённый тип неверным.
Свежесть данных о файле. Перед каждой отдачей сведения о файле — размер и время изменения — запрашиваются у файловой системы заново. Без этого сервер, работающий часами, мог бы объявить старый размер для файла, подменённого другим процессом, и ответ уехал бы обрезанным.
Исчезнувший файл. Если файл удалили в промежутке между созданием ответа и его
отправкой, отдача прерывается исключением RuntimeException. Альтернатива — отдать
пустой ответ с кодом 200, который клиент к тому же закеширует, — заметно хуже.
ResponseView
ResponseView — объект ответа для HTML-страницы, собранной из шаблона. Применяется там,
где сервер отдаёт разметку, а не данные: серверный рендеринг, письма, административные
интерфейсы.
Шаблоны, layout’ы и вспомогательные функции подробно описаны на странице Представления. Здесь — только место этого типа среди остальных.
view()
Отдаёт один шаблон без обёртки.
Синтаксис
public static function view(
string $resourceName,
array $data = [],
HttpCode $httpCode = HttpCode::OK,
): staticПараметры
$resourceName — путь к шаблону относительно каталога представлений, без расширения.
$data — данные, доступные внутри шаблона по именам ключей.
$httpCode — код состояния.
Пример
return ResponseView::view('errors/404', ['path' => '/orders/999'], HttpCode::NOT_FOUND);render()
Отдаёт шаблон, вложенный в общий макет страницы — тот, где объявлены <head>, шапка и
подвал.
Синтаксис
public static function render(
string $templateName,
string $resourceName,
array $data = [],
HttpCode $httpCode = HttpCode::OK,
): staticПараметры
$templateName — путь к макету.
$resourceName — путь к шаблону, который в этот макет подставится.
$data — данные, доступные и макету, и шаблону.
$httpCode — код состояния.
Пример
return ResponseView::render('layouts/main', 'orders/index', [
'title' => 'Заказы',
'orders' => [['id' => 1001, 'total' => 15400]],
]);Общее для всех типов
Заголовок X-Content-Type-Options
Оба файловых типа — ResponseFile и ResponseStreamFile — добавляют к ответу заголовок
X-Content-Type-Options: nosniff.
Он запрещает браузеру угадывать тип содержимого по самим байтам, вместо того чтобы верить
объявленному Content-Type. Угадывание опасно, когда файл загрузил пользователь:
безобидный на вид текстовый файл, содержащий разметку, браузер может решить показать как
HTML — и выполнить вложенный в него скрипт. Оба класса всегда объявляют тип явно, так что
угадывание им не нужно ни в одном сценарии.
Отключается методом sniffable(), если содержимое сформировано вами и вы действительно
хотите, чтобы тип определил браузер:
public function sniffable(bool $allow = true): staticОбратной операции нет: заголовок можно перекрыть значением, но не удалить, поэтому переключатель сделан отдельным методом.
Имя файла в заголовке
Имя, под которым файл предлагается сохранить, передаётся в заголовке
Content-Disposition. Формат этого заголовка не позволяет просто подставить туда
произвольную строку: кавычка в имени завершила бы значение раньше времени, а символы вне
латиницы в заголовках не допускаются вовсе.
Поэтому имя отправляется дважды — в упрощённом виде для старых клиентов и в закодированном для современных:
Content-Disposition: attachment; filename="_______ N14.pdf"; filename*=UTF-8''%D0%94%D0%BE%D0%B3%D0%BE%D0%B2%D0%BE%D1%80%20N14.pdfСовременный браузер возьмёт вторую форму и сохранит файл под настоящим именем. Старый
клиент возьмёт первую и сохранит под именем с подчёркиваниями вместо кириллицы. Ничего
делать для этого не нужно — заголовок собирается автоматически из имени, переданного в
fileName() или взятого из пути.
Запросы HEAD
HEAD — запрос, в котором клиент просит только заголовки ответа, без тела. Так узнают
размер файла перед скачиванием или проверяют, существует ли ресурс.
Отдельный обработчик писать не нужно: фреймворк выполняет тот же метод контроллера, что и
для GET, собирает ответ целиком, а тело отбрасывает перед отправкой. Заголовки при этом
совпадают с теми, что клиент получил бы обычным запросом, включая Content-Length.
Побочные действия на HEAD выполняются
Раз контроллер выполняется целиком, любой его побочный эффект произойдёт и при HEAD.
Счётчик скачиваний, увеличенный прямо в методе контроллера, посчитает запрос, при котором
клиент не получил ни одного байта содержимого.
Для файлов эта ловушка закрыта: beforeSend() на HEAD не вызывается.
Sendable
Sendable — интерфейс, который реализуют все четыре типа ответов. Если контроллер
возвращает объект, реализующий этот интерфейс, роутер отдаёт его напрямую, не оборачивая
ни во что.
Собственная реализация нужна тогда, когда протокол не укладывается в готовые типы:
поток событий, нестандартный двоичный формат, ответ с необычной логикой кеширования. Для
задачи «тот же JSON, но с парой заголовков» достаточно ResponseEntity.
Интерфейс
interface Sendable
{
public function send(HttpResponse $response, HttpRequest $request): void;
}Параметры метода
$response — объект, в который пишется ответ: status(), header(), cookie(),
end(), sendfile().
$request — входящий запрос. Передаётся потому, что многим ответам он нужен: чтобы
выбрать формат по Accept, прочитать запрошенный диапазон или условные заголовки.
Пример
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);
}
}Использование ничем не отличается от встроенных типов:
#[GetMapping('events')]
public function stream(): EventStreamResponse
{
return new EventStreamResponse([
['event' => 'order.created', 'data' => ['id' => 1001]],
['event' => 'order.paid', 'data' => ['id' => 1001]],
]);
}Дальше
- Куки — метод
cookie()подробно - Представления — шаблоны и макеты
- Обработка ошибок — исключения вместо возврата кода ошибки
- Контроллеры — где эти объекты возвращаются