Основы веб-разработки

Ответы

Контроллер во фреймворке не пишет в сокет и не вызывает echo. Он возвращает объект, описывающий будущий ответ, а превращением этого объекта в корректный HTTP занимается роутер.

Контракт SendableТипов 4Общее заголовки · куки

Что такое ответ и зачем он объектом

HTTP-ответ состоит из трёх частей: код состояния (число вроде 200 или 404), заголовки (пары «имя: значение», описывающие ответ) и тело (собственно данные). Кроме этого есть правила, которые браузер и промежуточные серверы ожидают увидеть соблюдёнными: длина тела должна совпадать с реально переданной, ответ 304 обязан быть без тела, запрос части файла должен получить код 206, а не 200.

Проблема. Если каждый контроллер собирает всё это сам, правила расползаются по проекту. Где-то забыт Content-Length, где-то 304 уходит с телом, где-то запрос HEAD — тот, в котором клиент просит только заголовки, — скачивает весь файл впустую. Ошибки такого рода не видны в разработке: браузер прощает многое, а падает потом на прокси или на мобильном клиенте.

Решение. Контроллер возвращает объект, который описывает что он хочет отдать: данные, файл, страницу. Как это станет правильным HTTP — задача самого объекта. Именно поэтому $response->end() в прикладном коде не встречается.

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' => 'Пользователь не найден']);
  }

  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 в зависимости от того, что попросил клиент; строка или число уходят как обычный текст.

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

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

Клиент получит:

text
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id":42,"name":"Анна"}

Фабрики состояния

Одиннадцать статических методов, каждый из которых создаёт объект с заранее выбранным кодом состояния. Нужны для того, чтобы не держать номера кодов в голове и не писать status(HttpCode::NOT_FOUND) там, где достаточно notFound().

Синтаксис

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

Параметры

$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 Ошибка на стороне сервера

Пример

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();
}

Ошибки удобнее бросать, чем возвращать

return ResponseEntity::notFound() работает, но заставляет вручную прокидывать результат через все уровни: сервис вернул null, контроллер это проверил, вернул ответ.

Исключение ResponseException всплывает откуда угодно — из репозитория, из сервиса — и превращается роутером в такой же ответ, без единой проверки по дороге:

throw new ResponseException('Пользователь не найден', HttpCode::NOT_FOUND);

Подробнее — на странице Обработка ошибок.

status()

Создаёт объект ответа с произвольным кодом состояния. Нужен, когда среди готовых фабрик подходящей нет: перечисление HttpCode содержит все коды HTTP, а фабрики покрывают только одиннадцать самых частых.

Синтаксис

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

Параметры

$code — вариант перечисления HttpCode, например HttpCode::TOO_MANY_REQUESTS.

Возвращает

Новый объект ответа без тела. Тело задаётся следом вызовом body().

Пример

php
use Flytachi\Winter\Base\HttpCode;

return ResponseEntity::status(HttpCode::TOO_MANY_REQUESTS)
  ->body(['message' => 'Слишком много запросов, попробуйте через минуту'])
  ->header('Retry-After', '60');

body()

Задаёт или заменяет тело ответа у уже созданного объекта.

Фабрики состояния принимают тело первым аргументом, поэтому отдельный вызов body() нужен в двух случаях: когда объект создан через status(), и когда тело вычисляется позже, чем выбирается код.

Синтаксис

php
public function body(mixed $body): static

Параметры

$body — новое тело. Прежнее, если оно было, замещается целиком.

Возвращает

Тот же объект ответа, поэтому вызов можно продолжать цепочкой.

Пример

php
$response = ResponseEntity::ok();

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

return $response;

Добавляет заголовок к ответу либо заменяет уже добавленный с тем же именем.

Заголовки хранятся картой «имя → значение», поэтому повторный вызов с тем же именем не добавляет второй заголовок, а перезаписывает первый. Для Set-Cookie — единственного заголовка, который по стандарту может повторяться, — предусмотрен отдельный метод cookie().

Синтаксис

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

Параметры

$name — имя заголовка, например Location или X-Request-Id.

$value — значение. Всегда строка: числа и логические значения нужно приводить самим.

Возвращает

Тот же объект ответа.

Пример

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

Результат

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

{"status":"ok"}

Прикрепляет к ответу куку — значение, которое браузер сохранит и будет присылать обратно при следующих запросах.

В отличие от заголовков, куки хранятся списком, а не картой: вызывать метод можно сколько угодно раз, и каждая кука уедет своим заголовком Set-Cookie. Сама кука описывается объектом SetCookie, у которого есть срок жизни, область видимости и флаги безопасности — это отдельная тема, разобранная на странице Куки.

Синтаксис

php
public function cookie(SetCookie $cookie): static

Параметры

$cookie — готовый объект куки. Создаётся через Cookie::make(), если нужно учесть схему запроса и настройки приложения, либо через SetCookie::make() для случая, когда запроса под рукой нет.

Возвращает

Тот же объект ответа.

Пример

php
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: сериализовать здесь нечего.

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

pong

Чтение собранного ответа

Четыре метода, позволяющие посмотреть, что уже собрано в объекте. Прикладному коду они обычно не нужны — их используют middleware, которое дополняет ответ контроллера, и тесты, проверяющие результат без запуска сервера.

Синтаксис

php
public function getCode(): HttpCode
public function getBody(): mixed
public function getHeaders(): array      // ['имя' => 'значение']
public function getCookies(): array      // list<SetCookie>

Пример

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 — объект ответа для файла, который приложение формирует прямо в момент запроса: выгрузка заказов в CSV, отчёт в JSON, дамп настроек. Файла на диске не существует и не появится — тело собирается в оперативной памяти из переданных данных и уходит клиенту.

Из этого следует главное ограничение: всё тело держится в памяти целиком. Для выгрузки на несколько мегабайт это нормально, для файла на сотни мегабайт — нет; такое нужно писать на диск и отдавать через ResponseStreamFile.

Класс сам проставляет заголовки, по которым браузер понимает, что делать с ответом: Content-Type (что за формат), Content-Disposition (скачать или показать), Content-Length (сколько байт), Cache-Control (сколько хранить копию).

csv()

Собирает CSV-файл из массива строк. Каждый элемент массива становится строкой файла, каждый элемент вложенного массива — ячейкой; значения экранируются по правилам CSV, так что запятые и кавычки внутри данных файл не ломают.

Синтаксис

php
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 — сколько секунд клиенту разрешено хранить копию. Ноль означает, что копию нужно перепроверять при каждом обращении.

Пример

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

Результат

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

json()

Собирает файл с JSON-содержимым. Отличается от ResponseEntity тем, что результат предлагается как файл, а не как тело API-ответа: у него есть имя и, при желании, диалог сохранения.

Синтаксис

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

Параметры

$data — массив, который будет закодирован в JSON, либо уже готовая JSON-строка.

$fileName — имя файла для клиента.

Остальные параметры совпадают с csv(), но $isAttachment по умолчанию false: JSON чаще смотрят в браузере, чем сохраняют.

Пример

php
return ResponseFile::json([
  'exportedAt' => '2026-08-21T10:00:00Z',
  'orders'     => [['id' => 1001, 'total' => 15400], ['id' => 1002, 'total' => 9800]],
], 'orders.json');

xml()

Собирает файл с XML-содержимым. Массив преобразуется в дерево элементов, где ключи становятся именами тегов.

Синтаксис

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

Параметры

$data — массив, объект, готовый SimpleXMLElement или скалярное значение.

Остальные параметры — как у json().

Пример

php
return ResponseFile::xml([
  'order' => ['id' => 1001, 'total' => 15400],
], 'order-1001.xml');

txt()

Собирает текстовый файл из произвольного значения. Значение приводится к строке, поэтому годится как для готового текста, так и для чисел.

Синтаксис

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

Пример

php
$report = "Заказов за день: 42\nОтменено: 3\nВыручка: 415 200\n";

return ResponseFile::txt($report, 'summary.txt');

binary()

Отдаёт произвольные двоичные данные как файл: сгенерированное изображение, PDF, архив, собранный в памяти.

Единственный из методов, у которого по умолчанию нет осмысленного типа содержимого — application/octet-stream означает «просто байты», и браузер такой ответ всегда предлагает сохранить. Указывайте настоящий тип третьим аргументом, если он известен.

Синтаксис

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

Пример

php
$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(): он не расходует память на размер файла и поддерживает частичную отдачу.

Синтаксис

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

Параметры

$filePath — абсолютный путь к файлу.

Пример

php
return ResponseFile::file('/var/www/app/resources/terms.pdf');

attachment() и inline()

Переключают поведение браузера: скачать файл или показать его во вкладке. Технически они меняют заголовок Content-Disposition — attachment вызывает диалог сохранения, inline предлагает браузеру открыть содержимое, если он умеет.

Каждая фабрика уже задаёт разумное умолчание, поэтому эти методы нужны там, где решение принимается по условию, а не выбором фабрики.

Синтаксис

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

Возвращает

Тот же объект ответа.

Пример

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

return $download
  ? $response->attachment()   // диалог «Сохранить как…»
  : $response->inline();      // открыть в просмотрщике браузера

maxAge()

Задаёт, сколько секунд клиенту разрешено хранить копию ответа, не обращаясь к серверу повторно.

Метод выставляет заголовок Cache-Control. Значение 0 (умолчание) означает, что копию нужно перепроверять при каждом обращении.

Синтаксис

php
public function maxAge(int $seconds): static

Параметры

$seconds — срок хранения копии в секундах.

Пример

php
return ResponseFile::binary($avatar, 'avatar.webp', 'image/webp')
  ->maxAge(60 * 60 * 24 * 30);   // месяц

Результат

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

Работают так же, как у остальных типов. header() и cookie() описаны выше, sniffable() — в общем разделе, поскольку относится к обоим файловым типам.


ResponseStreamFile

ResponseStreamFile — объект ответа для файла, который уже существует на диске: загруженный пользователем документ, видео, архив, резервная копия.

От ResponseFile отличается двумя вещами.

Файл не читается в память. Ядру операционной системы передаётся команда «отправь клиенту содержимое этого файла», и данные идут с диска в сеть, минуя память процесса. Поэтому отдать файл на несколько гигабайт стоит столько же памяти, сколько отдать маленький.

Это полноценный файловый сервер, а не просто отправка байтов. Он умеет то, чего браузеры и загрузчики ожидают от адреса, по которому лежит файл:

  • частичная отдача — клиент может попросить кусок файла («байты с 5000-го по 9999-й») и получить только его. На этом держится перемотка видео и докачка прерванной загрузки;
  • условные запросы — при повторном обращении клиент спрашивает «файл изменился?», и, если нет, получает короткий ответ без тела вместо повторной загрузки;
  • корректная обработка HEAD — запроса, в котором клиент просит только заголовки, чтобы узнать размер и тип, не скачивая содержимое.

Всё перечисленное включено сразу и работает без настройки.

open()

Создаёт объект ответа для файла по указанному пути.

Существование файла проверяется сразу: если файла нет, метод бросает исключение RuntimeException. Это сделано намеренно — упасть при построении ответа лучше, чем на середине его записи, когда клиенту уже ушли заголовки.

Синтаксис

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

Параметры

$filePath — абсолютный путь к существующему файлу.

$isAttachment — true вызывает диалог сохранения, false (по умолчанию) позволяет браузеру показать содержимое, если он умеет.

$httpCode — код состояния ответа.

$maxAge — срок хранения копии у клиента в секундах.

Возвращает

Объект ответа. Имя файла для клиента по умолчанию берётся из пути, тип содержимого определяется по самому файлу.

Ошибки

RuntimeException — если по указанному пути файла нет.

Пример

main/Controller/MediaController.php
#[GetMapping('media/{name}')]
public function show(#[PathVariable] string $name): ResponseStreamFile
{
  return ResponseStreamFile::open('/var/www/app/storage/media/' . $name);
}

fileName()

Задаёт имя, под которым файл будет предложен клиенту, когда оно не совпадает с именем на диске.

Такое расхождение — частый случай. Файлы, загруженные пользователями, обычно хранят под сгенерированными именами: так проще избежать коллизий и не хранить пользовательский ввод в путях файловой системы. Настоящее имя при этом лежит в базе данных.

Синтаксис

php
public function fileName(string $name): static

Параметры

$name — имя для клиента. Кодировать или экранировать его не нужно: заголовок соберётся корректно, включая имена с кириллицей и кавычками.

Возвращает

Тот же объект ответа.

Пример

php
// На диске: 9f2b1c4e8a.bin — сгенерированное имя без расширения.
// Клиенту показываем то, под которым файл загружали.
return ResponseStreamFile::open('/var/www/app/storage/uploads/9f2b1c4e8a.bin')
  ->fileName('Договор №14 от 01.08.2026.pdf')
  ->attachment();

contentType()

Задаёт тип содержимого явно, вместо того чтобы определять его по файлу.

Если не вызывать этот метод, тип определяется автоматически: файл открывается и у него читается начало, по которому распознаётся формат. Это надёжно, но не бесплатно — измеренная стоимость на файле в 2 МБ составляет около 0.9 мс. Когда тип уже известен приложению, эту работу незачем делать заново.

Для часто запрашиваемой статики метод особенно уместен. Тип содержимого объявляется не только вместе с файлом, но и в коротких ответах «не изменилось» — иначе кеш клиента заместил бы сохранённый тип неверным. А такие ответы у популярного файла случаются чаще всего: браузер, однажды скачавший картинку, при каждом следующем визите только переспрашивает, не изменилась ли она. Заданный явно тип снимает определение со всех этих обращений.

Синтаксис

php
public function contentType(string $mime): static

Параметры

$mime — тип содержимого, например application/pdf или video/mp4.

Возвращает

Тот же объект ответа.

Пример

php
return ResponseStreamFile::open('/var/www/app/storage/uploads/9f2b1c4e8a.bin')
  ->fileName('Договор №14.pdf')
  ->contentType('application/pdf');

Определение типа отложено до первого обращения

Автоматическое определение происходит не в open(), а тогда, когда тип впервые понадобился, и результат запоминается на время ответа. Файл ради этого открывается один раз, а не на каждый заголовок.

acceptRanges()

Включает или выключает поддержку частичной отдачи файла.

Частичная отдача — это возможность клиента запросить кусок файла вместо всего целиком. Браузер пользуется ею, когда пользователь перематывает видео на середину, а загрузчик — когда возобновляет прерванную закачку. Включена по умолчанию.

Выключать её стоит тогда, когда ответ обязан быть неделимым: одноразовая ссылка, платная загрузка со списанием, учёт скачиваний. При выключении сервер сообщает клиенту, что диапазоны не поддерживает, и присланный запрос куска игнорирует, отдавая файл целиком.

Синтаксис

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

Параметры

$enabled — false выключает частичную отдачу.

Возвращает

Тот же объект ответа.

Пример

php
return ResponseStreamFile::open('/var/www/app/storage/reports/2026-08.pdf')
  ->acceptRanges(false)
  ->attachment();

beforeSend()

Регистрирует функцию, которая будет вызвана непосредственно перед отправкой содержимого файла.

Метод нужен для действий, которые должны произойти ровно тогда, когда файл действительно уходит клиенту: увеличить счётчик скачиваний, погасить одноразовую ссылку, списать квоту. Написать это в контроллере нельзя: контроллер не знает, чем закончится ответ — отдачей файла, коротким ответом «не изменился» или отказом.

Синтаксис

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

Параметры

$hook — функция, принимающая один аргумент int $bytes — количество байт, которое объявит заголовок Content-Length. При частичной отдаче это размер куска, а не всего файла. Значение null снимает ранее установленную функцию.

Возвращает

Тот же объект ответа.

Когда вызывается

Что происходит Функция вызывается
Файл уходит целиком да, с размером файла
Уходит запрошенный кусок да, с размером куска
Файл не изменился, тело не передаётся нет
Запрошенный кусок вне файла, отказ нет
Пришёл запрос HEAD — только заголовки нет

Исключение, брошенное из функции, отменяет отдачу файла целиком. Это сделано намеренно: если списать скачивание не удалось, отдавать файл нельзя.

Пример

php
$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(), считает начатые загрузки, а не завершённые. Для счётчика скачиваний разница обычно несущественна, а для тарификации трафика — существенна, и знать об этом лучше заранее.

Совпадают с одноимёнными методами 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()

Отдаёт один шаблон без обёртки.

Синтаксис

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

Параметры

$resourceName — путь к шаблону относительно каталога представлений, без расширения.

$data — данные, доступные внутри шаблона по именам ключей.

$httpCode — код состояния.

Пример

php
return ResponseView::view('errors/404', ['path' => '/orders/999'], HttpCode::NOT_FOUND);

render()

Отдаёт шаблон, вложенный в общий макет страницы — тот, где объявлены <head>, шапка и подвал.

Синтаксис

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

Параметры

$templateName — путь к макету.

$resourceName — путь к шаблону, который в этот макет подставится.

$data — данные, доступные и макету, и шаблону.

$httpCode — код состояния.

Пример

php
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(), если содержимое сформировано вами и вы действительно хотите, чтобы тип определил браузер:

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

Обратной операции нет: заголовок можно перекрыть значением, но не удалить, поэтому переключатель сделан отдельным методом.

Имя файла в заголовке

Имя, под которым файл предлагается сохранить, передаётся в заголовке Content-Disposition. Формат этого заголовка не позволяет просто подставить туда произвольную строку: кавычка в имени завершила бы значение раньше времени, а символы вне латиницы в заголовках не допускаются вовсе.

Поэтому имя отправляется дважды — в упрощённом виде для старых клиентов и в закодированном для современных:

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

Интерфейс

php
interface Sendable
{
  public function send(HttpResponse $response, HttpRequest $request): void;
}

Параметры метода

$response — объект, в который пишется ответ: status(), header(), cookie(), end(), sendfile().

$request — входящий запрос. Передаётся потому, что многим ответам он нужен: чтобы выбрать формат по Accept, прочитать запрошенный диапазон или условные заголовки.

Пример

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);
  }
}

Использование ничем не отличается от встроенных типов:

php
#[GetMapping('events')]
public function stream(): EventStreamResponse
{
  return new EventStreamResponse([
      ['event' => 'order.created', 'data' => ['id' => 1001]],
      ['event' => 'order.paid',    'data' => ['id' => 1001]],
  ]);
}

Дальше