Запросы и привязка параметров
Winter сам наполняет аргументы метода данными запроса. Вы объявляете, что
нужно — тип и атрибут источника — а фреймворк читает запрос, приводит значение к
типу и передаёт его в метод. Никаких $_GET, $_POST и json_decode в коде
контроллера.
Что такое привязка параметров и зачем
Привязка параметров (parameter binding) — это автоматическое преобразование сырого HTTP-запроса в готовые, типизированные аргументы метода.
Проблема. Данные из HTTP приходят строками и лежат в разных местах: часть в
пути URL, часть в query-строке, часть в теле (JSON, форма или XML), что-то в
заголовках. Разбирать их вручную, проверять наличие, приводить "42" к int —
это рутина, которая повторяется в каждом методе и легко даёт ошибки. Причём ошибки
тихие: забытая проверка на null всплывёт не здесь, а тремя слоями глубже.
Решение. Опишите параметр типом и атрибутом источника — остальное сделает
фреймворк: найдёт значение в нужном месте запроса, приведёт к объявленному типу и
передаст в метод, а при несоответствии вернёт 400 ещё до входа в ваш код.
Начнём, однако, с того, что лежит под привязкой, — с самого объекта запроса.
Объект запроса
HttpRequest — это весь входящий запрос одним объектом. Чтобы его получить,
достаточно объявить аргумент такого типа; никакого атрибута не нужно:
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
#[PostMapping('webhook')]
public function webhook(HttpRequest $request): ResponseEntity
{
$signature = $request->getHeader('X-Signature');
$payload = $request->getRawBody();
// ...
}Это интерфейс, а не класс: под ним лежит либо SwooleRequest, либо FpmRequest,
в зависимости от того, как приложение запущено. Ваш код разницы не видит — в этом и
смысл.
Данные запроса
| Метод | Что возвращает |
|---|---|
getMethod() |
HTTP-глагол в верхнем регистре: GET, POST, … |
getUri() |
Путь вместе с query-строкой: /users/42?page=1 |
getQueryParams() |
Разобранная query-строка массивом — аналог $_GET |
getParsedBody() |
Разобранное тело формы — аналог $_POST |
getRawBody() |
Тело запроса как есть, байтами |
getUploadedFiles() |
Загруженные файлы — аналог $_FILES |
Заголовки
| Метод | Что возвращает |
|---|---|
getHeader(string $name) |
Один заголовок или null. Имя регистронезависимо |
getHeaders() |
Все заголовки; ключи приведены к нижнему регистру |
Кто и откуда пришёл
Эта группа отвечает на вопросы, ответ на которые за обратным прокси не совпадает с тем, что видит сокет. Все методы учитывают заголовки проксирования:
| Метод | Что возвращает |
|---|---|
getClientIp() |
IP клиента с учётом X-Forwarded-For и Forwarded |
getScheme() |
http или https — с учётом X-Forwarded-Proto |
getHost() |
Имя хоста без порта, как его набрал клиент |
getPort() |
Порт, как его видел клиент, с учётом X-Forwarded-Port |
getBaseUrl() |
Готовое scheme://host[:port], стандартные порты опущены |
getClientTimezone() |
Часовой пояс из заголовка Timezone или X-Timezone |
getServerParam(string $key) |
Переменная окружения запроса: remote_addr, request_time… |
Заголовкам прокси доверяют безоговорочно
getScheme(), getHost(), getPort() и getClientIp() берут значения из
Forwarded и X-Forwarded-*, не проверяя, кто их поставил. Это правильно за
обратным прокси, который эти заголовки перезаписывает, и опасно без него: их
может прислать кто угодно. Если приложение смотрит в интернет напрямую, вырезайте
их на входе.
Нужен и объект ответа — объявляется так же, по типу HttpResponse. Он пригождается
редко: обычно ответ собирают возвратом значения из метода, см.
Контроллеры.
Как работает привязка
Обращаться к HttpRequest напрямую приходится нечасто — почти всё, что из него
достают, фреймворк умеет разложить по аргументам сам.
Когда маршрут найден, перед вызовом метода отрабатывает ParameterResolver. Он
идёт по параметрам метода и для каждого:
- читает объявленный тип и атрибут источника;
- находит значение в нужном месте запроса;
- проверяет, обязателен ли параметр, если значения нет;
- приводит к объявленному PHP-типу;
- подставляет в аргумент.
#[GetMapping('orders/{id:\d+}')]
public function show(
#[PathVariable] int $id, // из пути
#[RequestParam] int $page = 1, // из query ?page=
#[RequestHeader] string $authorization, // из заголовка
): ResponseEntity {
// всё уже найдено, проверено и приведено к типам
}Сравните с тем же кодом на сыром запросе — три обращения, три проверки на отсутствие, два приведения типа и ни одного внятного сообщения об ошибке.
Откуда брать данные
Каждому месту в запросе — свой атрибут. Все живут в
Flytachi\Winter\Kernel\Http\Request\Annotation:
| Атрибут | Откуда берёт | Что принимает |
|---|---|---|
#[PathVariable] |
Сегмент пути — /users/{id} |
Скаляр |
#[RequestParam] |
Один параметр query — ?key=val |
Скаляр или массив |
#[RequestQuery] |
Всю query-строку целиком | Только объект или array |
#[RequestHeader] |
Заголовок запроса | Скаляр |
#[RequestBody] |
Тело; формат определяется по Content-Type |
Строку, массив, объект, DTO |
#[RequestJson] |
Тело — принудительно как JSON | Массив, stdClass, DTO |
#[RequestForm] |
Тело — принудительно как форму | Массив, stdClass, DTO |
#[RequestXml] |
Тело — принудительно как XML | Массив, stdClass, DTO |
#[RequestFile] |
Загруженный файл | Массив с данными файла или его содержимое |
Дальше — по одному разделу на источник.
Путь — #[PathVariable]
Берёт динамический сегмент URL. Имя аргумента должно совпадать с именем сегмента в маршруте:
#[GetMapping('users/{id}/posts/{slug}')]
public function post(
#[PathVariable] int $id, // /users/42/... → 42
#[PathVariable] string $slug, // .../hello → "hello"
): ResponseEntity { /* ... */ }Связь идёт по имени, а не по позиции — порядок аргументов значения не имеет.
Аргументы атрибута
| Аргумент | По умолчанию | Что делает |
|---|---|---|
name |
имя аргумента | Имя сегмента в маршруте, если оно отличается |
#[GetMapping('posts/{id}')]
public function show(#[PathVariable('id')] int $postId): ResponseEntity { /* ... */ }Сегмент обязателен: если маршрут совпал, значение есть всегда. Подробнее про сами шаблоны путей и ограничения регулярными выражениями — на странице Маршрутизация.
Query-строка
Для query есть два атрибута, и выбор между ними — это выбор между «несколько отдельных значений» и «один объект фильтра».
Одно значение — #[RequestParam]
#[GetMapping('orders')]
public function list(
#[RequestParam] int $page = 1, // ?page=2
#[RequestParam] ?string $search = null, // ?search=telefon
#[RequestParam] bool $active = false, // ?active=true
#[RequestParam] array $ids = [], // ?ids[]=1&ids[]=2
): ResponseEntity { /* ... */ }Имя ищется в трёх написаниях. Аргумент $pageSize поймает ?pageSize=,
?page_size= и ?page-size= — фронтенду не нужно подстраиваться под стиль
именования PHP.
Массив приходит массивом строк
?ids[]=1&ids[]=2 даст ["1", "2"], а не [1, 2]: элементы не приводятся к типу,
потому что объявить его негде — array в PHP не типизирован. Приводите сами или
опишите поле в DTO.
Аргументы атрибута
| Аргумент | По умолчанию | Что делает |
|---|---|---|
name |
имя аргумента | Точное имя параметра в query |
#[RequestParam('per_page')] int $perPage = 20, // только ?per_page=Явное имя отключает поиск в трёх написаниях — совпадение проверяется только
точное. Это ловушка: при #[RequestParam('page_size')] присланный ?pageSize=25
не подойдёт и молча уйдёт в значение по умолчанию.
Много значений — #[RequestQuery]
Когда параметров фильтра много, вместо десятка аргументов удобнее один объект. Поля
заполняются из query по именам и приводятся к типам конструктора — по тем же
правилам, что и #[RequestParam]:
class OrderFilter
{
public function __construct(
public readonly int $page = 1,
public readonly int $limit = 20,
public readonly ?string $search = null,
) {}
}#[GetMapping('orders')]
public function list(#[RequestQuery] OrderFilter $filter): ResponseEntity
{
// ?page=2&limit=50&search=nokia → $filter->page === 2 (int, не строка)
}Атрибут всегда необязателен: пустая query-строка даёт объект со значениями по умолчанию, а не ошибку. Поэтому у полей DTO имеет смысл всегда задавать дефолты.
Кроме своего класса принимает array (тогда придёт вся query как есть) и
stdClass. Чтобы поля ещё и проверялись, добавьте #[Valid] — см.
Валидацию.
Скаляр здесь роняет запрос
#[RequestQuery] принимает только объект или array. На int, string и прочие
скаляры он бросает LogicException, то есть 500, а не 400:
Request query string parameter '$page' has unsupported type 'int'
Для одного значения нужен #[RequestParam]. Ошибка легко проходит ревью, потому
что выглядит правдоподобно.
Заголовки — #[RequestHeader]
Имя заголовка выводится из имени аргумента: camelCase и snake_case переводятся
в kebab-case, а поиск идёт без учёта регистра.
public function handle(
#[RequestHeader] string $authorization, // Authorization
#[RequestHeader] ?string $xRequestedWith, // X-Requested-With
#[RequestHeader] ?string $accept_language, // Accept-Language
): ResponseEntity { /* ... */ }Аргументы атрибута
| Аргумент | По умолчанию | Что делает |
|---|---|---|
name |
имя аргумента в kebab-case | Точное имя заголовка |
#[RequestHeader('X-Trace-Id')] string $trace,Явное имя удобно, когда заголовок не переводится в имя переменной красиво, — но
регистр по-прежнему не важен, так что 'x-trace-id' и 'X-Trace-Id' равнозначны.
Отсутствующий заголовок — это 400
Заголовок, как и остальные источники, обязателен по умолчанию. Клиент, забывший
Authorization, получит 400, а не null внутри метода. Чтобы сделать заголовок
необязательным, объявите тип ?string или задайте значение по умолчанию.
Для нескольких заголовков сразу или для тех, что нужны условно, проще взять
HttpRequest и спросить getHeader() — привязка не обязана покрывать всё.
Тело запроса
Тело — самый нагруженный источник: в нём приходит JSON, форма или XML, а положить его нужно то в объект, то в массив, то целиком в строку. Поэтому атрибутов здесь четыре: один с автоопределением формата и три с явно заданным.
#[RequestBody] — формат по Content-Type
Основной вариант. Формат определяется по заголовку, а во что разобрать — по типу аргумента:
#[PostMapping('orders')]
public function create(#[RequestBody] CreateOrderDto $dto): ResponseEntity
{
// тело разобрано и разложено по полям DTO
}| Тип аргумента | Что придёт |
|---|---|
| Класс DTO | Объект, поля заполнены из тела и приведены к типам конструктора |
array |
Разобранное тело массивом |
stdClass |
Разобранное тело объектом |
string |
Сырое тело байтами, Content-Type игнорируется |
Dto ...$items |
Список объектов из JSON-массива |
Формат определяется по Content-Type:
| Заголовок | Как разбирается |
|---|---|
application/xml, text/xml |
Как XML |
application/x-www-form-urlencoded, multipart/form-data |
Как форма |
| Всё остальное | Как JSON |
То есть форма разбирается наравне с JSON — отдельный атрибут для неё нужен только тогда, когда формат надо зафиксировать, не полагаясь на заголовок клиента.
Сырое тело
Аргумент типа string отдаёт тело как есть, ничего не разбирая. Это то, что нужно
для вебхуков с подписью: подпись считается по исходным байтам, и любой разбор с
пересборкой её ломает.
#[PostMapping('webhook')]
public function webhook(
#[RequestBody] string $raw,
#[RequestHeader] string $xSignature,
): ResponseEntity {
if (!hash_equals($xSignature, hash_hmac('sha256', $raw, env('WEBHOOK_SECRET')))) {
throw new RequestException('Bad signature');
}
// ...
}Одно поле — аргумент field
Когда из тела нужно одно значение, необязательно заводить под него класс. Аргумент
field достаёт значение по ключу и приводит к типу аргумента:
#[PatchMapping('{id:\d+}/name')]
public function rename(
#[PathVariable] int $id,
#[RequestBody(field: 'name')] string $name,
): ResponseEntity { /* {"name": "..."} → $name */ }Поддерживается точечная нотация для вложенных структур:
#[RequestJson(field: 'user.email')] string $email, // {"user":{"email":"..."}}
#[RequestBody(field: 'filter.minPrice')] int $min, // приведёт к intЗначение обязательно по умолчанию, а ограничения на таком аргументе срабатывают
сами — #[Valid] для них не нужен:
#[RequestBody(field: 'name'), Size(5, 40)] string $name,Аргумент field есть у всех четырёх атрибутов тела.
Принудительный формат
Когда клиент присылает неверный Content-Type или формат нужно зафиксировать,
берите атрибут с явным форматом вместо автоопределения:
| Атрибут | Как разбирает тело |
|---|---|
#[RequestJson] |
Всегда как JSON |
#[RequestForm] |
Всегда как форму, независимо от заголовка |
#[RequestXml] |
Всегда как XML |
public function a(#[RequestJson] CreateOrderDto $dto): ResponseEntity {}
public function b(#[RequestForm] array $form): ResponseEntity {}
public function c(#[RequestXml] \stdClass $node): ResponseEntity {}Правила по типам аргумента и field у них те же, что у #[RequestBody].
Массив объектов
Вариадик-параметр разворачивает JSON-массив в набор DTO — для массовых операций:
#[PostMapping('orders/bulk')]
public function bulk(#[RequestBody, Valid] OrderDto ...$orders): ResponseEntity
{
// [{"..."}, {"..."}] → $orders[0], $orders[1], ...
}Тело обязано быть JSON-массивом; объект или что-то иное даст 400 с сообщением
Expected JSON array for variadic body. Ошибки нумеруются по индексу элемента,
так что видно, какой именно объект в пачке не прошёл.
Ошибки разбора
Поля DTO проверяются на наличие и тип, и все ошибки собираются разом, а не по первой:
POST /orders {}
400 {"name": ["is required"], "qty": ["is required"]}Это проверка структуры: поле есть, поле нужного типа. Содержательные правила —
длина, диапазон, формат — включаются атрибутом #[Valid] на параметре, см.
Валидацию.
Файлы
#[RequestFile] привязывает файл, загруженный через multipart/form-data. Кроме
самой привязки атрибут умеет проверять размер и тип — то есть отсеивать негодную
загрузку до входа в ваш метод.
Один файл
Аргумент типа array получает данные о файле — те же ключи, что в $_FILES:
#[PostMapping('avatar')]
public function upload(#[RequestFile('avatar')] array $file): ResponseEntity
{
// ['name' => 'photo.jpg', 'type' => 'image/jpeg',
// 'tmp_name' => '/tmp/php7Xy', 'size' => 12345, 'error' => 0]
move_uploaded_file($file['tmp_name'], $target);
}Если нужно не описание, а содержимое — объявите аргумент string, и файл будет
прочитан за вас:
#[PostMapping('import')]
public function import(#[RequestFile('report')] string $csv): ResponseEntity
{
// $csv — байты файла; временный файл читать самому не нужно
}Несколько файлов
Поле с <input name="images[]" multiple> разбирается флагом multiple. Приходит
список, даже если файл прислали один:
#[PostMapping('gallery')]
public function gallery(#[RequestFile('images', multiple: true)] array $images): ResponseEntity
{
foreach ($images as $image) { /* каждый — такой же массив данных */ }
}Без имени поля приходит вся карта загруженных файлов — ключи полей формы, значения описания:
#[RequestFile] array $files, // ['avatar' => [...], 'images' => [[...], [...]]]Проверка размера и типа
| Аргумент | По умолчанию | Что делает |
|---|---|---|
name |
— | Имя поля формы. Без него приходит вся карта файлов |
multiple |
false |
Ожидать список файлов вместо одного |
maxSize |
нет | Предельный размер; сверх — 400 |
accept |
нет | Допустимые типы; не подошёл — 400 |
#[RequestFile('avatar', maxSize: '5MB', accept: ['image/jpeg', 'image/png'])] array $avatar,maxSize понимает B, KB, MB, GB и дробные значения ('1.5MB'); единицы
двоичные, 1KB — это 1024 байта. Голое число считается байтами. При
multiple: true предел применяется к каждому файлу отдельно.
accept принимает три формы записи:
| Форма | Пример | Как проверяется |
|---|---|---|
| Точный MIME | 'application/pdf' |
По содержимому файла |
| MIME с маской | 'image/*' |
По содержимому файла |
| Расширение | '.pdf' |
По имени файла от клиента |
Расширение — не проверка
Первые две формы определяют тип по сигнатуре файла, через finfo, а не по
Content-Type, который прислал браузер: переименованный или подменённый в запросе
файл они отсекут.
Форма с расширением сравнивает лишь имя, пришедшее от клиента. Переименованный
shell.php в shell.pdf её пройдёт. Пользуйтесь ею только там, где тип не влияет
на безопасность, — во всех остальных случаях перечисляйте MIME.
Чего ждать при ошибке
Все проверки отвечают 400 и не пускают запрос в контроллер:
| Что случилось | Сообщение |
|---|---|
| Поле не прислали | Uploaded file 'avatar' is missing |
| Сбой передачи | Uploaded file 'avatar' transfer error (code 1) |
| Слишком большой | Uploaded file 'avatar' exceeds maximum size of 5MB |
| Неподходящий тип | Uploaded file 'avatar' type 'application/pdf' is not allowed (accepted: image/jpeg, image/png) |
Файл можно сделать необязательным обычным способом — значением по умолчанию или
типом ?array:
#[RequestFile('avatar')] ?array $avatar = null, // без файла придёт nullРазмер запроса ограничен отдельно
Загрузка крупнее, чем позволяет maxRequestSize сервера (по умолчанию 8 МБ), не
дойдёт до этих проверок вовсе — сервер ответит 413 раньше. Поднимается это в
настройках веб-слоя, см. Настройка веб-слоя.
Приведение типов
Из HTTP всё приходит строками. Резолвер приводит значение к типу, объявленному у
аргумента, и если привести не получается — отвечает 400 ещё до входа в метод.
| Тип аргумента | Что принимает | Пример |
|---|---|---|
int |
Целое число строкой | "42" → 42 |
float |
Число с точкой | "3.14" → 3.14 |
bool |
true/false, 1/0, yes/no, on/off |
"yes" → true |
string |
Что угодно | принимается как есть |
array |
Только массив (?ids[]=) |
скаляр отвергается |
| Backed enum | Значение из перечисления | "draft" → Status::Draft |
DateTimeImmutable, DateTime |
Дату в ISO 8601 | "2024-01-31T12:00:00" |
BcMath\Number, Decimal\Decimal |
Числовую строку | точное число без потерь |
Сообщения об ошибке называют и параметр, и ожидание:
?page=abc → 400 Query parameter 'page' must be an integer, got 'abc'
?status=hz → 400 Query parameter 'status' must be one of [draft, published], got 'hz'
?from=31-01 → 400 Query parameter 'from' has invalid date '31-01' — expected ISO 8601Точные числа
BcMath\Number и Decimal\Decimal получают значение строкой, минуя float,
поэтому "1.1" остаётся 1.1, а не превращается в 1.1000000000000000888.
Для денег это единственный безопасный вариант. Оба типа требуют своего расширения
PHP — без него значение придёт как есть, без приведения.
Массив вместо скаляра
?id[]=1 при аргументе int $id даст 400 must be int, got array — клиент не
подсунет массив туда, где ждали одно значение. Исключение — типы mixed и
iterable: они массив принимают.
Обязательные и опциональные
Параметр из HTTP-источника обязателен по умолчанию. Необязательным его делают двумя способами:
int $page // обязателен: нет значения → 400
int $page = 1 // нет значения → 1
?int $page // нет значения → null
?int $page = null // то же самое, но нагляднееПорядок разрешения при отсутствии значения: есть значение по умолчанию — вернуть
его; тип допускает null — вернуть null; иначе — 400.
Пустая строка — это присутствующее значение
?page (параметра нет) и ?page= (параметр есть, но пустой) — разные случаи.
Первый берёт значение по умолчанию, второй идёт в приведение типа, а там результат
зависит от типа:
| Тип | ?x= даёт |
|---|---|
int, float |
400 — пустая строка не число |
DateTimeImmutable, enum |
400 |
bool |
false — пустая строка считается ложью |
string |
Пустую строку, это валидное значение |
То есть ?active= не ошибка, а выключенный флаг.
Правила разрешения
Резолвер проверяет источники в фиксированном порядке и берёт первое совпадение. Знать порядок нужно в одном случае: если на параметре оказалось два атрибута, сработает тот, что выше.
1. #[PathVariable] сегмент пути
2. #[RequestParam] один параметр query
3. #[RequestBody] тело, формат по Content-Type
4. #[RequestFile] загруженный файл
5. #[RequestJson] тело как JSON
6. #[RequestForm] тело как форма
7. #[RequestXml] тело как XML
8. #[RequestQuery] вся query-строка
9. #[RequestHeader] заголовок
10. тип HttpRequest объект запроса
11. тип HttpResponse объект ответа
12. совпадение по имени сегмент пути без атрибута
13. значение по умолчанию
14. тип допускает null → nullПункт 12 объясняет, почему #[PathVariable] можно не писать: если имя аргумента
совпадает с именем сегмента, значение найдётся и без атрибута. С атрибутом код
читается однозначнее, поэтому в примерах он оставлен.
Если не подошло ни одно правило, запрос падает с RuntimeException:
Cannot resolve parameter '$foo' in PostController::create()
— add an annotation or a default valueЭто ошибка не клиента, а сигнатуры метода, поэтому ответ будет 500. И проявится
она при обращении к эндпоинту, а не при запуске приложения: резолвер работает
на каждом запросе. Проверяйте новые обработчики хотя бы одним вызовом.
Один тип на параметр
Объединённые и пересечённые типы на HTTP-параметрах не поддерживаются — резолвер отвергает их до всякого приведения:
// ✗ LogicException: Union/intersection type on '$value' is not supported
public function a(#[RequestParam] int|string $value): void {}
// ✓ один тип
public function b(#[RequestParam] string $value): void {}Причина простая: у объединения нет одного правила приведения — непонятно, чем
должна стать строка "42".
Дальше
- Валидация —
#[Valid]и содержательные проверки полей - Маршрутизация — шаблоны путей для
#[PathVariable] - Ответы — форматы и коды ответа
- Обработка ошибок — во что превращается
400 - Контроллеры — где живут эти сигнатуры