Маршрутизация
Маршрут в Winter — это атрибут на методе контроллера. Нет файлов
маршрутов, нет таблиц регистрации: вы вешаете #[GetMapping] на метод, а сканер
сам находит его при старте и связывает с URL. Код контроллера и есть карта
маршрутов — что видите, то и обслуживается.
Что такое маршрутизация и зачем
Маршрутизация — это сопоставление входящего запроса (HTTP-метод + путь URL)
с кодом, который его обработает. Приходит GET /api/posts/42 — фреймворку нужно
понять, какой именно метод какого класса вызвать.
Проблема. Связь «URL → обработчик» нужно где-то описать и поддерживать. Если
хранить её отдельно от кода (в файле маршрутов или конфиге), появляется два
источника правды: добавили метод в контроллере — не забудь дописать строку в
routes.php. Со временем список маршрутов и код расходятся.
Решение. Winter описывает маршрут там же, где обработчик — атрибутом на методе. Один источник правды: метод и его URL всегда рядом. Об этом и раздел.
Первый маршрут
Чтобы у приложения появился эндпоинт, нужны три вещи:
- Класс наследует стереотип
Controller. - Публичный метод помечен атрибутом HTTP-метода —
#[GetMapping],#[PostMapping]и так далее. - Файл лежит внутри каталогов, которые фреймворк сканирует (о них — ниже).
Ни ручной регистрации, ни строки в конфиге: создали метод с атрибутом — маршрут появился.
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
use Flytachi\Winter\Kernel\Route\Annotation\GetMapping;
class PingController extends Controller
{
#[GetMapping('ping')]
public function ping(): ResponseEntity
{
return ResponseEntity::ok('pong');
}
}Запускаем приложение и проверяем:
php call run
# слушает 0.0.0.0:8000 — переопределяется флагами --host и --port
curl http://localhost:8000/ping
# pongГде фреймворк ищет контроллеры
Обход начинается с корня проекта и захватывает каталог src/ каждого
подключённого плагина. Конкретное расположение файла роли не играет — контроллер
находят по атрибутам, а не по пути.
Три каталога из обхода исключены, и об этом стоит помнить:
| Каталог | Почему исключён |
|---|---|
vendor/ |
Чужой код; маршруты оттуда приходят только через плагины |
storage/ |
Сгенерированный код: кеш контейнера, прокси #[Async] |
resources/ |
Шаблоны представлений — файлы .php, но не классы приложения |
Контроллер, положенный в resources/, найден не будет.
Зависимости внутри контроллера
Controller::__construct() объявлен final и без аргументов — переопределить
конструктор нельзя, поэтому конструкторная инъекция в контроллере недоступна.
Зависимости внедряются в свойства атрибутом #[Autowired]:
class PostController extends Controller
{
#[Autowired] private PostService $service;
}Экземпляр контроллера создаёт контейнер, поэтому к моменту вызова метода свойство уже заполнено. Подробности — на странице Внедрение зависимостей.
Новый маршрут не появился?
Карта маршрутов собирается один раз при старте сервера, а не на каждый запрос.
После обычного php call run добавленный контроллер требует перезапуска. В
разработке запускайте php call run dev — сторож следит за .php-файлами и
перезапускает приложение сам при любом изменении.
HTTP-методы
Каждому HTTP-глаголу — свой атрибут. Все принимают один аргумент — путь
(по умолчанию пустой) и живут в пространстве
Flytachi\Winter\Kernel\Route\Annotation.
| Атрибут | HTTP-метод | Назначение |
|---|---|---|
#[GetMapping] |
GET | Чтение ресурса |
#[PostMapping] |
POST | Создание |
#[PutMapping] |
PUT | Полное обновление |
#[PatchMapping] |
PATCH | Частичное обновление |
#[DeleteMapping] |
DELETE | Удаление |
#[RequestMapping] |
— | На классе — префикс. На методе — сразу пять глаголов: GET, POST, PUT, PATCH, DELETE |
Путь в атрибуте пишется без ведущего слэша — он подставляется автоматически. Поставите свой — он будет отброшен, так что оба варианта рабочие:
#[GetMapping] // GET / (пустой путь)
#[GetMapping('health')] // GET /health
#[PostMapping('users')] // POST /usersOPTIONS регистрировать не нужно: preflight-запросы фреймворк обрабатывает сам,
до поиска маршрута — см. CORS.
#[RequestMapping] на методе
У этого атрибута две роли. На классе он задаёт префикс — об этом ниже. А на методе, где глагол не указан, он вешает обработчик сразу на все пять глаголов для одного пути. Пригодится для вебхуков и универсальных обработчиков, где важен путь, а не метод запроса:
#[RequestMapping('webhook')]
public function webhook(HttpRequest $request): ResponseEntity
{
// сюда придут GET, POST, PUT, PATCH и DELETE на /webhook
return ResponseEntity::ok();
}Именно пять, а не «любой метод»: нестандартные глаголы так не зарегистрируются.
Какой глагол пришёл на самом деле, обработчик узнаёт у запроса —
$request->getMethod().
HEAD и OPTIONS в этот список не входят, но обрабатываются и без него: OPTIONS
перехватывается как preflight, а HEAD подставляется к обработчику GET
автоматически.
Дополнительные атрибуты маршрута
Помимо глагола и пути на маршрут вешаются ещё два атрибута. Оба ставятся и на класс (тогда действуют на все его методы), и на отдельный метод — метод перекрывает класс.
| Атрибут | Что задаёт |
|---|---|
#[CrossOrigin] |
CORS-политику этого маршрута вместо глобальной — см. CORS |
#[Timeout] |
Дедлайн маршрута в секундах вместо глобального |
#[CrossOrigin] пригождается, когда одному эндпоинту нужна политика строже или
мягче общей: например, весь сервис открыт основному фронтенду, а сводка по
метрикам — только админке, и с кэшем preflight на час.
#[CrossOrigin(origins: ['https://app.example.com'], credentials: true)]
class UserController extends Controller
{
#[GetMapping('me')] // политика класса
public function me(): ResponseEntity { /* ... */ }
#[GetMapping('stats')]
#[CrossOrigin(origins: ['https://admin.example.com'], maxAge: 3600)]
public function stats(): ResponseEntity { /* ... */ } // своя политика
}Важно: атрибут заменяет глобальную политику целиком, а не дополняет её. Что означает каждый параметр и как настроить политику по умолчанию — на странице CORS.
#[Timeout] нужен там, где обычного лимита не хватает: глобально запрос живёт
30 секунд, а выгрузка отчёта — десять минут.
#[Timeout(120)] // весь контроллер: две минуты
class ReportController extends Controller
{
#[GetMapping('export')]
#[Timeout(600)] // ...а этой выгрузке — десять
public function export(): ResponseEntity { /* ... */ }
#[GetMapping('ping')]
#[Timeout(0)] // ...а этот не ограничен вовсе
public function ping(): ResponseEntity { /* ... */ }
}По истечении дедлайна клиент получает 504 Gateway Timeout.
Что дедлайн может прервать
Прерывается запрос, который чего-то ждёт — базы, внешнего HTTP, файла,
sleep(). Блоки finally и defer при этом отрабатывают, так что транзакции
закрываются, а соединения возвращаются в пул. Запрос, который жжёт процессор в
цикле без ввода-вывода, не прерывается: пока он не отпустит поток, в воркере не
выполняется вообще ничего, включая сам сторож.
Префикс маршрутов
#[RequestMapping('prefix')] на классе задаёт общий префикс для всех методов
контроллера. Путь метода дописывается к нему через слэш:
#[RequestMapping('api/v1/orders')]
class OrderController extends Controller
{
#[GetMapping] // GET /api/v1/orders
public function index(): ResponseEntity { /* ... */ }
#[GetMapping('{id}')] // GET /api/v1/orders/{id}
public function show(#[PathVariable] int $id): ResponseEntity { /* ... */ }
#[PostMapping] // POST /api/v1/orders
public function create(): ResponseEntity { /* ... */ }
}Префикс удобно использовать для версии API (api/v1/...) или логического раздела
(admin/...), не повторяя базовый путь в каждом методе. Меняете версию — правите
одну строку.
Метод с пустым путём отвечает ровно по префиксу — отдельного «корневого» маршрута
объявлять не нужно. Лишние слэши по краям нормализуются, так что
#[RequestMapping('/api/v1/')] и #[GetMapping('/ping')] вместе дают
/api/v1/ping, а не /api/v1//ping.
Общий базовый контроллер
Методы, унаследованные от базового класса, регистрируются в каждом наследнике — и получают префикс наследника, а не базы. Это штатный способ раздать нескольким ресурсам одинаковый набор эндпоинтов:
abstract class CrudController extends Controller
{
#[GetMapping('list')]
public function list(): ResponseEntity { /* ... */ }
}
#[RequestMapping('api/posts')]
class PostController extends CrudController {} // GET /api/posts/list
#[RequestMapping('api/users')]
class UserController extends CrudController {} // GET /api/users/listСам базовый класс маршрутов не даёт: абстрактные классы, интерфейсы и трейты сканер пропускает.
У наследников должны быть разные префиксы
Префикс класса не наследуется — #[RequestMapping] читается только с самого
класса. Если два наследника общей базы остались без собственного префикса, оба
попытаются занять один и тот же путь, и приложение не поднимется:
RuntimeException: Ambiguous handler methods mapped for [GET] '/list'
Конфликт маршрутов виден при старте, а не в проде.
Префикс плагина
Контроллеры, приехавшие из плагина, дополнительно получают его префикс — тот, под которым плагин подключён к приложению. Префиксы складываются слева направо:
префикс плагина + префикс класса + путь метода
/billing orders {id}
→ GET /billing/orders/{id}Поэтому внутри плагина пути пишутся без оглядки на приложение-хозяина: за разведение по неймспейсам URL отвечает тот, кто плагин подключает. Подробнее — на странице Пакеты.
Параметры пути
Динамические сегменты объявляются в фигурных скобках — {name}. Значение
привязывается к аргументу метода атрибутом #[PathVariable] и приводится к типу
аргумента:
#[GetMapping('users/{id}/posts/{slug}')]
public function post(
#[PathVariable] int $id, // "42" → int 42
#[PathVariable] string $slug, // "hello" → string
): ResponseEntity {
// /users/42/posts/hello → $id = 42, $slug = "hello"
}Связь идёт по имени, а не по позиции: аргументы можно объявлять в любом порядке, лишь бы имя совпадало с именем сегмента. Число сегментов не ограничено.
Если имя аргумента отличается от имени в пути — передайте его в атрибут явно:
#[GetMapping('posts/{id}')]
public function show(#[PathVariable('id')] int $postId): ResponseEntity { /* ... */ }Типы аргументов
Сегмент URL — всегда строка, но в метод она приходит уже приведённой к объявленному типу. Если привести не получается, до тела контроллера дело не доходит: клиент получает 400 Bad Request с внятным текстом.
| Тип аргумента | Что принимает | При несоответствии |
|---|---|---|
int, float |
Числовой сегмент | Path variable 'id' must be an integer, got 'abc' |
bool |
true/false, 1/0, yes/no |
Сообщение со списком допустимых форм |
string |
Любой сегмент | — |
| backed enum | Значение из перечисления | must be one of [draft, published], got 'x' |
DateTimeImmutable |
Разбираемую дату | Сообщение о формате |
Enum в пути особенно удобен — проверка допустимых значений достаётся бесплатно:
enum Status: string { case Draft = 'draft'; case Published = 'published'; }
#[GetMapping('posts/status/{status}')]
public function byStatus(#[PathVariable] Status $status): ResponseEntity { /* ... */ }
// /posts/status/draft → Status::Draft
// /posts/status/hz → 400: must be one of [draft, published], got 'hz'Аргумент с #[PathVariable] обязателен. Чтобы сделать его необязательным, дайте
значение по умолчанию или объявите тип nullable — иначе при отсутствии сегмента
будет 400 с текстом Path variable 'id' is missing.
Атрибут можно не писать
Если имя аргумента совпадает с именем сегмента, #[PathVariable] необязателен —
резолвер найдёт значение по имени. Но с атрибутом код читается однозначно, поэтому
в примерах он оставлен.
Ограничение регулярным выражением
К сегменту можно добавить regex через двоеточие — {name:pattern}. Маршрут
сработает, только если сегмент подходит под шаблон; иначе роутер вернёт 404,
не заходя в контроллер:
#[GetMapping('{id:\d+}')] // только цифры: /42 ✓ /abc ✗
#[GetMapping('{slug:[a-z-]+}')] // строчный slug: /hello ✓ /Hi ✗
#[GetMapping('files/{path:.+}')] // остаток пути: /a/b/c ✓ (слэши внутри)| Приём | Что даёт |
|---|---|
Без regex — {id} |
Совпадает с любым непустым сегментом, кроме / |
С regex — {id:\d+} |
Отсекает несовпадения на уровне маршрута (404 до контроллера) |
{path:.+} |
Захватывает и слэши — так делается «остаток пути» |
| Приведение типа | #[PathVariable] int $id конвертирует строку в нужный тип |
Что в шаблоне писать нельзя
Шаблон подставляется в общий regex маршрута, и две конструкции его ломают:
- Фигурные скобки — квантификаторы
{3},{2,4}. Закрывающая скобка обрывает сегмент, и маршрут молча перестаёт совпадать: вместо ошибки вы получаете 404 на всех запросах. Вместо{code:[A-Z]{3}}пишите{code:[A-Z][A-Z][A-Z]}. - Захватывающие группы —
(cat|dog). Лишняя группа сдвигает нумерацию параметров и роняет диспетчер на запросе. Используйте незахватывающие —{kind:(?:cat|dog)}.
Regex или валидация?
Regex в пути — это грубый фильтр «подходит / не подходит», и результат у него один:
404, как будто такого адреса нет. Для содержательной проверки с понятным сообщением
об ошибке вешайте на тот же аргумент ограничения — они срабатывают на
#[PathVariable] автоматически, #[Valid] для этого не нужен:
#[PathVariable, Min(1)] int $id
Тогда вместо 404 клиент получит 400 с описанием, что именно не так. См. Запросы и ответы.
CRUD-шаблон
Разберём типичный ресурс целиком — от скаффолда до готового контроллера. Это эталонная форма, к которой сводится большинство API-эндпоинтов, и она собрана из всего, что было выше: префикс класса, параметр пути с ограничением, атрибуты глаголов.
Скаффолд
Заготовку контроллера создаёт генератор. Суффикс Controller он добавляет сам,
так что имя ресурса пишется коротко:
php call make -c .Post
# → main/PostController.phpВнутри — рабочий метод-заглушка, чтобы приложение поднялось сразу:
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
class PostController extends Controller
{
#[RequestMapping('post')]
public function hello(): ResponseEntity
{
return ResponseEntity::ok("hello");
}
}Заглушку принято сразу заменять: #[RequestMapping] на методе занимает пять
глаголов на одном пути, а нужен обычно один конкретный.
Куда именно ляжет файл
По умолчанию — в корневой каталог приложения (main/). Но если в проекте уже
есть каталог Controllers/, Controller/, Rests/ или Rest/, генератор
положит файл туда и подставит соответствующий неймспейс. Остальные флаги
(-s сервис, -r репозиторий и другие) — на странице
CLI → make.
Контроллер
Приведём PostController к полному набору CRUD-операций. Обратите внимание, как
каждый метод отражает свой HTTP-глагол и возвращает подходящий код ответа:
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Request\Annotation\PathVariable;
use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestJson;
use Flytachi\Winter\Kernel\Http\Request\Annotation\RequestParam;
use Flytachi\Winter\Kernel\Http\Request\Validation\Valid;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
use Flytachi\Winter\Kernel\Route\Annotation\DeleteMapping;
use Flytachi\Winter\Kernel\Route\Annotation\GetMapping;
use Flytachi\Winter\Kernel\Route\Annotation\PostMapping;
use Flytachi\Winter\Kernel\Route\Annotation\PutMapping;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
#[RequestMapping('api/posts')] // общий префикс → /api/posts
class PostController extends Controller
{
#[Autowired] private PostService $service; // зависимость через контейнер
#[GetMapping] // GET /api/posts
public function index(#[RequestParam] int $page = 1): ResponseEntity
{
return ResponseEntity::ok($this->service->paginate($page));
}
#[GetMapping('{id:\d+}')] // GET /api/posts/{id}
public function get(#[PathVariable] int $id): ResponseEntity
{
return ResponseEntity::ok($this->service->find($id));
}
#[PostMapping] // POST /api/posts → 201
public function create(#[RequestJson, Valid] PostRequest $req): ResponseEntity
{
return ResponseEntity::created($this->service->create($req));
}
#[PutMapping('{id:\d+}')] // PUT /api/posts/{id}
public function update(
#[PathVariable] int $id,
#[RequestJson, Valid] PostRequest $req,
): ResponseEntity {
return ResponseEntity::ok($this->service->update($id, $req));
}
#[DeleteMapping('{id:\d+}')] // DELETE /api/posts/{id} → 204
public function delete(#[PathVariable] int $id): ResponseEntity
{
$this->service->delete($id);
return ResponseEntity::noContent();
}
}Что здесь работает вместе с маршрутизацией:
#[Autowired] PostService— контроллер не создаёт сервис руками, его внедряет контейнер. См. Внедрение зависимостей.#[RequestParam] int $page = 1— одиночный параметр строки запроса (?page=2), необязательный благодаря значению по умолчанию.#[RequestJson, Valid] PostRequest— тело запроса разбирается в объект (PostRequest— обычный класс с полями) и валидируется до входа в метод. См. Запросы и ответы.{id:\d+}— числовое ограничение отсекает/api/posts/abcещё до контроллера.- Коды ответа —
ResponseEntity::ok()(200),created()(201),noContent()(204). Полный список — на странице ответов.
Проверка результата
Убедиться, что все пять маршрутов зарегистрировались, можно не запуская сервер:
php call mapping show api/posts
| GET /api/posts → Main\PostController::index
| POST /api/posts → Main\PostController::create
| GET /api/posts/{id:\d+} → Main\PostController::get
| PUT /api/posts/{id:\d+} → Main\PostController::update
| DELETE /api/posts/{id:\d+} → Main\PostController::deleteАргумент — фрагмент URL для фильтра; без него команда покажет все маршруты приложения.
Маршрутизация — это только вход
Контроллер остаётся тонким: принял запрос, делегировал сервису, вернул ответ.
Бизнес-логика живёт в Service, доступ к данным — в Repository. Такое
разделение задаёт стереотипы Winter (см. Ключевые понятия).
Несколько путей на один метод
Атрибуты глаголов повторяемы — один обработчик может отвечать сразу на несколько путей. Удобно для синонимов и обратной совместимости, когда старый адрес надо сохранить, а логика у него та же:
#[GetMapping('search')]
#[GetMapping('filter')]
public function search(#[RequestParam] string $q = ''): ResponseEntity
{
// обслуживает и GET /search, и GET /filter
}Глаголы тоже можно смешивать — атрибуты на методе не обязаны быть одного типа:
#[GetMapping('search')]
#[PostMapping('search')]
public function search(): ResponseEntity
{
// GET /search и POST /search — один обработчик
}Каждый атрибут даёт отдельную запись в таблице маршрутов, и все они
подчиняются общим правилам: наследуют префикс класса, попадают в call mapping show отдельными строками. Обработчик при этом один, и по параметрам он не может
отличить, каким путём его вызвали, — если это важно, спросите у запроса
$request->getUri().
Пара «глагол + путь» должна быть уникальной
Два одинаковых атрибута на одном методе — не «на всякий случай», а конфликт:
приложение упадёт при старте с RuntimeException: Ambiguous handler methods mapped for [GET] '/search'. Повторять можно только разные пути или разные глаголы.
Обнаружение и просмотр маршрутов
Таблица маршрутов собирается один раз при старте сервера, в мастер-процессе, до того как разойдутся воркеры. Каждый воркер получает её уже готовой.
Из этого следуют две вещи, важные на практике:
- На запросе сканирования нет. Рефлексия, обход файлов, чтение атрибутов —
всё это стоимость запуска, а не стоимость запроса. Поиск по готовой таблице
устроен так, что путь без параметров находится за одно обращение к хеш-карте
независимо от общего числа маршрутов; перебором проверяются только пути
с
{...}. - Изменения в коде подхватываются только рестартом. Добавили контроллер или
поправили путь — сервер надо перезапустить. Режим
DEBUGна это не влияет: таблица собирается одинаково и в разработке, и в проде.
Поэтому в разработке приложение запускают через php call run dev — сторож
следит за .php-файлами и перезапускает процесс сам, так что новый маршрут
появляется сразу после сохранения файла.
Перед деплоем ничего собирать не нужно
Отдельного шага сборки маршрутов в конвейере деплоя не требуется: таблица строится при запуске приложения, и отдельного файла с маршрутами не существует.
Ускорить старт можно другим: call di build готовит список классов проекта, и
сканер маршрутов берёт его же вместо повторного обхода файлов — см.
CLI → di.
Просмотр маршрутов
Посмотреть, что в итоге зарегистрировалось — глаголы, пути и обработчики:
php call mapping show # все маршруты приложения
php call mapping show api/posts # только те, где путь содержит 'api/posts'Команда не поднимает сервер, поэтому годится и для быстрой проверки после правки,
и для диагностики: если маршрута нет в выводе — значит, контроллер не попал в скан
(проверьте, что файл лежит не в resources/ и класс не абстрактный).
Тот же сканирующий проход попутно собирает не только маршруты: обработчики
исключений #[AdviceException], маршруты плагинов и эндпоинты health-актуатора —
всё обнаруживается за один обход проекта.
Ответы 404 и 405
- Путь не найден →
404 Not Found. - Путь есть, но метод не тот →
405 Method Not Allowed. В ответ добавляется заголовокAllowсо списком методов, зарегистрированных для этого пути — клиент сразу видит, что доступно.
Дальше
- Контроллеры — стереотип
Controllerи структура обработчика - Запросы и ответы — привязка параметров,
#[PathVariable], валидация - CORS — глобальная политика и
#[CrossOrigin]на маршрутах - Внедрение зависимостей — как
#[Autowired]наполняет контроллер