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

Ответы

Контроллер возвращает ответ, а не пишет в поток руками. Чаще всего это ResponseEntity — данные вместе с HTTP-кодом. Фреймворк сам подберёт формат, выставит заголовки и отправит всё одинаково под любым рантаймом.

Данные ResponseEntityФайлы ResponseFile / ResponseStreamFileКонтракт Sendable

Что такое ответ и зачем

Ответ — это то, что уходит клиенту: код состояния, заголовки и тело.

Проблема. Собирать его вручную — значит в каждом обработчике выставить статус, сериализовать данные, не забыть Content-Type и Content-Length, закрыть соединение. Кода немного, но он транспортный: к задаче эндпоинта отношения не имеет, повторяется везде и под Swoole с PHP-FPM пишется по-разному. Забытый заголовок при этом проявится не ошибкой, а странным поведением у клиента.

Решение. Верните из метода объект ответа. Он знает, каким кодом отвечать, как сериализовать тело и какие заголовки нужны, а отправкой занимается фреймворк — одинаково при любом способе запуска.

Что можно вернуть

php
return ResponseEntity::ok($data);       // объект ответа — обычный путь
return $this->service->all();          // просто данные — обернутся в 200
throw new ResponseException(...);      // ошибка — станет ответом сама

Первый вариант нужен, когда важны код или заголовки; второй — когда это обычная успешная выдача. Третий разобран в Обработке ошибок.

`null` — это не пустой ответ

Метод, вернувший null или объявленный как void, не отправляет ничего: фреймворку нечего сериализовать, и клиент остаётся ждать. Если тело не нужно, возвращайте ResponseEntity::noContent() — это корректный 204.

ResponseEntity — данные

Основной тип ответа для API. Фабрика задаёт код, аргумент становится телом.

Готовые коды

Фабрика Код Когда
ok($body) 200 Успешная выдача
created($body) 201 Ресурс создан
accepted($body) 202 Принято в обработку, результата пока нет
noContent() 204 Сделано, отвечать нечем
badRequest($body) 400 Клиент прислал ерунду
unauthorized($body) 401 Не представился
forbidden($body) 403 Представился, но нельзя
notFound($body) 404 Нет такого
conflict($body) 409 Состояние не позволяет
unprocessable($body) 422 Понятно, но невыполнимо
internalError($body) 500 Сломались мы

Тело у всех необязательно: ResponseEntity::notFound() вернёт код без тела.

Любой код и заголовки

php
use Flytachi\Winter\Base\HttpCode;

return ResponseEntity::status(HttpCode::IM_A_TEAPOT)->body($data);

return ResponseEntity::ok($data)
  ->header('X-Request-Id', $requestId)
  ->header('X-Total-Count', (string) $total);

HttpCode — перечисление всех кодов, так что подсказка редактора избавляет от магических чисел. Вызовы body() и header() цепляются.

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

Метод Что вернёт
getCode() Код как HttpCode
getBody() Тело до сериализации
getHeaders() Добавленные заголовки

Пригождается в after() у middleware — например, чтобы завернуть тело в общий конверт, не трогая обработчики. См. Middleware.

Согласование формата

Как сериализовать тело, решает тип тела, а для структур — ещё и заголовок Accept клиента.

Тело Что уходит
Массив или объект JSON или XML — по Accept, по умолчанию JSON
Строка, число, bool Всегда text/plain; charset=utf-8
null или код 204 Пустое тело
Заголовок Accept Формат ответа
application/json JSON
application/xml XML
text/html, */* или заголовка нет JSON

То есть return ResponseEntity::ok('pong') отдаст текст, а не JSON-строку — на это стоит обратить внимание, если клиент разбирает ответ как JSON безусловно.

Объект, у которого есть метод toArray(), перед сериализацией проходит через него — сущности и DTO отдаются как есть, разворачивать их вручную не нужно.

Можно вернуть просто массив

Не-Sendable значение роутер обернёт сам: return ['key' => 'value'] равносильно ResponseEntity::ok(['key' => 'value']). Для быстрых эндпоинтов это самый короткий путь; объект ответа нужен, когда важны код или заголовки.

ResponseFile — файлы и выгрузки

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

Фабрика Что принимает
csv($rows, $fileName) Массив строк — соберёт CSV
json($data, $fileName) Массив или готовую строку
xml($data, $fileName) SimpleXMLElement, объект, массив или скаляр
txt($text, $fileName) Текст
binary($bytes, $fileName) Произвольные байты
file($absolutePath) Путь к файлу на диске; имя и MIME определит сам

Имя файла обязательно у всех, кроме file().

php
#[GetMapping('report')]
public function report(): ResponseFile
{
  return ResponseFile::csv($this->service->rows(), 'report.csv');
}

Настройка отдачи

php
return ResponseFile::csv($rows, 'export.csv')
  ->attachment()                    // диалог сохранения
  ->inline()                        // показать в браузере
  ->maxAge(3600)                    // Cache-Control: public, max-age=3600
  ->header('X-Source', 'generated');

По умолчанию binary и csv уходят как вложение, остальные — на просмотр.

Каждый ответ этого типа выставляет Content-Length и отключает сжатие (Content-Encoding: identity): иначе объявленная длина разойдётся с фактической, и клиент получит обрезанный файл.

ResponseStreamFile — большие файлы

ResponseFile::file() читает файл в память целиком, а значит упирается в лимит памяти воркера — общий на все одновременные запросы. Для видео, архивов и дампов берите потоковую отдачу: файл уходит с диска напрямую, минуя кучу PHP.

php
return ResponseStreamFile::open('/var/media/video.mp4');                  // просмотр
return ResponseStreamFile::open('/var/export/dump.sql')->attachment();   // скачивание

Это не просто «отдать файл» — ответ ведёт себя как полноценный файловый сервер:

Возможность Что даёт клиенту
HTTP Range → 206 Перемотку видео и докачку прерванной загрузки
ETag и Last-Modified Ответ 304, когда файл не менялся
If-Range Безопасное продолжение докачки, если файл успел измениться

Отключить частичную отдачу — ->acceptRanges(false): сервер объявит Accept-Ranges: none и проигнорирует Range. Нужно там, где важна атомарность выдачи: подсчёт скачиваний, одноразовые ссылки.

Что выбрать

ResponseFile::file() ResponseStreamFile::open()
Читается в память целиком нет
Range и 206 нет да
Условный запрос и 304 нет да
Для чего небольшие файлы большие файлы и медиа

HTML — ResponseView

Серверный рендеринг шаблонов с макетами и данными — отдельная тема, разобранная на странице Представления.

php
return ResponseView::render('layouts/main', 'users/index', ['users' => $users]);

Запросы HEAD

На HEAD фреймворк отдаёт те же статус и заголовки, что отдал бы на GET, включая Content-Length, но без тела — подавление происходит централизованно, каждому типу ответа заботиться об этом не нужно.

Отдельный маршрут не нужен

Обработчик, объявленный через #[GetMapping], отвечает и на HEAD — роутер подставляет его сам, как того требует спецификация HTTP. Отдельный маршрут заводят только если HEAD должен обрабатываться иначе, чем GET; такой маршрут имеет приоритет.

Если пути нет ни под GET, ни под HEAD, ответ прежний — 404 или 405 со списком доступных методов.

Свои ответы — Sendable

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

main/IcsResponse.php
<?php

namespace Main;

use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Response\Sendable;

class IcsResponse implements Sendable
{
  public function __construct(private string $calendar, private string $fileName) {}

  public function send(HttpResponse $response, HttpRequest $request): void
  {
      $response->status(200);
      $response->header('Content-Type', 'text/calendar; charset=utf-8');
      $response->header('Content-Disposition', "attachment; filename=\"{$this->fileName}\"");
      $response->end($this->calendar);
  }
}

Вернули такой объект из метода — фреймворк вызовет send() и больше ни во что не вмешается.

Метод получает и запрос тоже — чтобы можно было посмотреть на Accept, обработать Range или ответить 304. Не нужен — просто не используйте аргумент.

Через HttpResponse доступны четыре операции: status(), header(), end() и sendfile(). За этим интерфейсом стоит либо Swoole, либо PHP-FPM, поэтому ваш ответ работает при любом способе запуска без единой правки.

Встроенные реализации: ResponseEntity, ResponseFile, ResponseStreamFile, ResponseView.

Дальше