Контроллеры
Контроллер принимает HTTP-запрос и возвращает ответ. В Winter это класс,
наследующий стереотип Controller; методы с атрибутами маршрутов — обработчики.
Сам класс намеренно «пустой»: зависимости приходят через контейнер, а всё
поведение задаётся атрибутами.
Что такое контроллер и зачем
Контроллер — это граница между HTTP и вашим приложением: точка, куда приходит запрос и откуда уходит ответ.
Проблема. Логике приложения нельзя напрямую зависеть от HTTP: если разбор запроса, вызов бизнес-логики и формирование ответа перемешаны в одном месте, код тяжело тестировать и переиспользовать — ту же операцию не вызвать из консольной команды или фоновой задачи.
Решение — тонкий слой-обработчик: контроллер принимает запрос, делегирует работу сервису и возвращает ответ, не смешивая транспорт с логикой.
В Winter Controller — это стереотип: базовый класс с чёткой ролью «вход HTTP». Он
намеренно минимален — по сути маркер, по которому сканер находит обработчики, а
контейнер знает, как построить экземпляр:
namespace Flytachi\Winter\Kernel\Http\Stereotype;
abstract class Controller implements ControllerInterface
{
final public function __construct() {}
}Одна деталь этого класса определяет почти всё, как вы будете с ним работать:
конструктор объявлен final и без аргументов. Отсюда два следствия:
- Конструктор не переопределить. Конструкторной инъекции у контроллера нет —
зависимости приходят в свойства через
#[Autowired]. new UserController()вы не пишете. Экземпляр создаёт фреймворк на каждый запрос, попутно разрешая зависимости.
Создание
Заготовку создаёт генератор — суффикс Controller он добавляет сам:
php call make -c .User # → main/UserController.phpКаркас минимален: класс, стереотип и один метод-обработчик.
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;
use Flytachi\Winter\Kernel\Route\Annotation\GetMapping;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
#[RequestMapping('users')]
class UserController extends Controller
{
#[GetMapping]
public function index(): ResponseEntity
{
return ResponseEntity::ok([]);
}
}Чтобы фреймворк подхватил класс как контроллер, он должен отвечать трём условиям:
| Условие | Почему |
|---|---|
Наследует Controller |
По нему сканер отличает обработчики от прочих классов |
| Не абстрактный | Абстрактные классы, интерфейсы и трейты сканер пропускает |
| Лежит в сканируемом каталоге | Каталоги resources/, storage/ и vendor/ исключены |
Обработчиками становятся публичные методы с атрибутами маршрутов. Как эти атрибуты устроены — глаголы, префиксы, параметры пути — на странице Маршрутизация; здесь дальше речь о самом классе.
Зависимости
Контроллеру почти всегда нужны сервисы и репозитории. Их не создают через new —
их внедряет контейнер. Конструктор для этого недоступен, поэтому способ один:
типизированное свойство с атрибутом #[Autowired].
use Flytachi\Winter\DI\Attribute\Autowired;
#[RequestMapping('users')]
class UserController extends Controller
{
#[Autowired] private UserService $service;
#[Autowired] private LoggerInterface $logger;
#[GetMapping('{id}')]
public function get(#[PathVariable] int $id): ResponseEntity
{
$this->logger->info('fetch user', ['id' => $id]);
return ResponseEntity::ok($this->service->find($id));
}
}Свойство можно объявлять private — контейнер заполняет его до того, как
управление дойдёт до вашего метода. Тип свойства и есть то, что будет внедрено.
Логгер по типу
#[Autowired] LoggerInterface $logger даёт логгер, автоматически именованный по
классу-потребителю — писать getLogger(self::class) не нужно, имя канала
подставит контейнер. Механика — в доках logger.
Подробно о ручных привязках, #[Lazy] и настройке контейнера — на странице
Внедрение зависимостей.
Время жизни контроллера
Контроллер создаётся заново на каждый запрос. Экземпляр не переиспользуется и не разделяется между запросами, поэтому свойства безопасно наполнять данными текущего запроса — соседний запрос их не увидит.
Это поведение по умолчанию: класс без атрибута области видимости считается
transient, то есть строится заново при каждом обращении к контейнеру.
Области видимости
Поведение меняется атрибутом на классе. Для контроллеров это редко нужно, но знать таблицу полезно — те же атрибуты вешаются на ваши сервисы:
| Атрибут | Сколько экземпляров | Когда уместно |
|---|---|---|
| нет (по умолчанию) | Новый на каждое внедрение | Всё, что хранит состояние |
#[Singleton] |
Один на процесс-воркер | Классы без состояния: фабрики, пулы, клиенты |
#[Request] |
Один на запрос (в Swoole — на корутину) | Контекст запроса: авторизация, unit of work |
#[Transient] |
Новый на каждое внедрение — явно | Когда хочется сказать это в коде |
Не вешайте `#[Singleton]` на контроллер
С этим атрибутом экземпляр становится общим для всех запросов воркера и всех корутин внутри него. Свойство, в которое обработчик положил данные текущего пользователя, тут же становится гонкой: соседний запрос прочитает чужое. Обычному контроллеру область видимости задавать не нужно — умолчание уже правильное.
Обратите внимание и на обратную сторону: сервис без #[Singleton] тоже строится
заново на каждый запрос. Если он открывает соединение или держит прогретый кеш,
это стоит денег — пометьте его явно.
Конфликт областей ловится при старте
Комбинация «#[Singleton], внутри которого лежит #[Request]-зависимость» —
скрытая ошибка: request-объект первого запроса замёрз бы в синглтоне на всё время
жизни воркера. Winter обходит граф зависимостей при загрузке и падает с
ScopeConflictException, показывая цепочку A → $prop: B, а не даёт приложению
подняться с такой связкой.
Возврат ответа
Что метод вернул, то фреймворк и отправит. Есть два пути: вернуть готовый объект ответа или просто данные.
Просто данные
Любое не-null значение оборачивается в ответ 200 автоматически. Для быстрых
эндпоинтов этого достаточно:
#[GetMapping('ping')]
public function ping(): string
{
return 'pong'; // 200, text/plain
}
#[GetMapping]
public function index(): array
{
return $this->service->all(); // 200, application/json
}Тип возвращаемого значения решает, каким будет Content-Type:
| Что вернули | Content-Type |
|---|---|
| Массив или объект | Согласуется с заголовком Accept; по умолчанию application/json |
Строка, число, bool |
Всегда text/plain; charset=utf-8 |
null (или void) |
Ответ не отправляется — см. предупреждение ниже |
Объект, у которого есть метод toArray(), перед сериализацией через него и
проходит — сущности и DTO отдаются как есть, вручную разворачивать не нужно.
`null` — это не пустой ответ
Обработчик, вернувший null или объявленный как void, не отправляет ничего:
фреймворку нечего сериализовать, и клиент остаётся без ответа. Если тело
действительно не нужно, возвращайте ResponseEntity::noContent() — это корректный
204.
Готовый объект ответа
Когда нужен конкретный код, заголовки или особый формат — возвращайте объект,
реализующий Sendable. Их четыре:
| Класс | Для чего |
|---|---|
ResponseEntity |
Данные и коды состояния — основной тип для API |
ResponseView |
Серверный HTML из шаблонов |
ResponseFile |
Файлы и выгрузки, собранные в памяти |
ResponseStreamFile |
Отдача файла с диска потоком, без загрузки в память |
ResponseEntity — данные и коды
Фабрика задаёт HTTP-код, аргумент становится телом:
return ResponseEntity::ok($data); // 200
return ResponseEntity::created($data); // 201
return ResponseEntity::accepted($data); // 202
return ResponseEntity::noContent(); // 204
return ResponseEntity::badRequest($err); // 400
return ResponseEntity::notFound(); // 404
return ResponseEntity::status(HttpCode::IM_A_TEAPOT)->body($data); // любой код
// Заголовок — builder-стилем, вызовы можно цеплять:
return ResponseEntity::ok($data)->header('X-Total-Count', (string) $total);Есть ещё unauthorized(), forbidden(), conflict(), unprocessable() и
internalError() — полный список на странице ответов.
Ошибку удобнее бросить, чем вернуть
Возвращать ResponseEntity::notFound() из глубины метода — значит тащить проверку
через все ветки. Вместо этого бросьте исключение: фреймворк сам превратит его в
ответ с нужным кодом. См. Обработку ошибок.
ResponseView — HTML
Для серверного рендеринга шаблонов. view() рендерит ресурс сам по себе,
render() оборачивает его в макет — внутри макета готовый HTML выводится вызовом
wrContent():
// Ресурс внутри макета: resources/views/layouts/main.php + resources/views/users/index.php
return ResponseView::render('layouts/main', 'users/index', ['users' => $users]);
// Только ресурс, без макета — например, фрагмент для htmx:
return ResponseView::view('users/row', ['user' => $user]);Третий аргумент — данные, доступные в шаблоне как переменные. Подробнее — на странице Представления.
ResponseFile — файлы и выгрузки
Собирает файл в памяти и отдаёт клиенту. Имя файла обязательно во всех фабриках,
кроме file():
return ResponseFile::csv($rows, 'report.csv'); // CSV-выгрузка
return ResponseFile::json($payload, 'export.json'); // JSON-файл
return ResponseFile::xml($data, 'feed.xml'); // XML
return ResponseFile::txt($text, 'notes.txt'); // текст
return ResponseFile::binary($bytes, 'image.png'); // произвольные байты
return ResponseFile::file('/abs/path/report.pdf'); // файл с дискаПо умолчанию csv и binary отдаются как вложение (диалог «сохранить»), а
json, xml, txt и file — как содержимое для просмотра. Переключает это
аргумент $isAttachment.
ResponseStreamFile — большие файлы
Для видео, архивов и всего, что не стоит целиком поднимать в память. Файл уходит
потоком, а ответ ведёт себя как полноценный файловый сервер: поддерживает HTTP
Range (докачка и перемотка), условные запросы и валидаторы ETag /
Last-Modified.
return ResponseStreamFile::open('/abs/path/video.mp4');
// Скачивание вместо просмотра, с кешированием на час:
return ResponseStreamFile::open($path)->attachment()->maxAge(3600);
// Отдать целиком, запретив докачку по частям:
return ResponseStreamFile::open($path)->acceptRanges(false);Разница с ResponseFile::file() — в памяти: та читает файл целиком, эта отдаёт
его напрямую, поэтому размер файла не упирается в лимит памяти воркера.
Тонкий контроллер
Контроллер — это вход, а не место для бизнес-логики. Держите его тонким: принял
запрос, делегировал сервису, вернул ответ. Логика — в Service, доступ к данным —
в Repository:
#[PostMapping]
public function create(#[RequestJson, Valid] CreateUserRequest $req): ResponseEntity
{
// никакой логики здесь — только оркестрация
$user = $this->service->register($req);
return ResponseEntity::created($user);
}Обратите внимание, чего в этом методе нет: разбора тела запроса, проверки полей, ручного формирования JSON. Тело уже разобрано в объект и провалидировано до входа в метод, ответ соберётся из возвращённого значения. Контроллеру остаётся один содержательный вызов.
Почему так
Тонкие контроллеры проще тестировать и переиспользовать: одну и ту же логику
сервиса можно вызвать из контроллера, консольной команды или фоновой задачи.
Разделение Controller → Service → Repository — базовая модель Winter (см.
Ключевые понятия).
Middleware на контроллере
Всё, что нужно сделать до обработчика или после него — проверить авторизацию, замерить время, обогатить ответ — выносится в middleware. К контроллеру оно подключается атрибутом: на классе (тогда действует на все методы) или на отдельном методе.
#[AuthMiddleware] // на весь контроллер
#[RequestMapping('admin')]
class AdminController extends Controller
{
#[AuditMiddleware] // ...и дополнительно только на этот метод
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }
}Класс middleware — это ещё и атрибут
Чтобы класс можно было повесить атрибутом, он должен не только наследовать
стереотип Middleware, но и сам быть объявлен атрибутом. Без строки
#[Attribute] PHP откажется его применять:
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class AuthMiddleware extends Middleware
{
public function before(HttpRequest $request, HttpResponse $response): void
{
// проверка перед обработчиком
}
}Генератор делает это за вас — php call make -m .Auth создаёт заготовку уже с
нужной строкой.
Порядок выполнения
Middleware класса выполняются раньше, чем middleware метода. before() идут в
порядке объявления, after() — в обратном, как вложенные скобки:
AuthMiddleware::before() ← сначала класс
AuditMiddleware::before() ← затем метод
метод контроллера
AuditMiddleware::after()
AuthMiddleware::after() ← и обратно наружуПрервать запрос можно прямо из before() — достаточно бросить исключение: до
обработчика дело не дойдёт, а исключение превратится в HTTP-ответ обычным путём.
Middleware с параметрами
Аргументы, переданные атрибуту, попадают в конструктор middleware. Так один класс обслуживает разные правила без дублирования:
#[RoleMiddleware('admin')]
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }Сам middleware создаёт контейнер, поэтому в нём работает и #[Autowired] —
зависимости внедряются так же, как в контроллере.
Как писать before() / after() подробно, что можно вернуть из after() и какие
middleware есть из коробки — на странице Middleware.
Дальше
- Маршрутизация — атрибуты глаголов, префиксы, параметры пути
- Запросы и ответы — привязка входных данных и форматы ответов
- Представления — шаблоны, макеты и данные для
ResponseView - Обработка ошибок — исключения вместо кодов ответа
- Внедрение зависимостей — как наполняется
#[Autowired] - Middleware — пред- и постобработка запроса