HTTP и ядро¶
Namespaces Keel\Http и Keel\Kernel. PSR-7 сообщения — nyholm/psr7, PSR-15 middleware,
PSR-17 фабрики. Ядро — один объект на воркер, без состояния запроса.
Конвейер запроса¶
ExceptionHandlerMiddleware → глобальные middleware → RoutingMiddleware
→ middleware маршрута (класс, затем метод) → контроллер → Responder
$kernel = Kernel::build(
$definitions, // Keel\Container\Definitions
[UserController::class], // контроллеры с атрибутами
[CorsMiddleware::class], // глобальные middleware, по порядку
debug: $env === 'dev',
);
$response = $kernel->handle($request); // ServerRequestInterface → ResponseInterface
$kernel->terminate($request, $response); // после отправки: ResponseSent, отложенные задачи, Container::reset()
Kernel::build() регистрирует в контейнере Psr17Factory под всеми PSR-17 интерфейсами,
Responder, ArgumentResolver и Router, если приложение не сделало этого само. Для
скомпилированного контейнера и кэша маршрутов используйте конструктор
new Kernel($container, $router, $middleware, $debug); Kernel::registerDefaults() нужно
вызвать перед компиляцией. terminate() рассылает ResponseSent, выполняет DeferredTasks и
сбрасывает сервисы; см. Рантаймы.
Pipeline — PSR-15 обработчик: middleware выполняются в порядке объявления (FIFO), классы
резолвятся из контейнера на каждом запросе, так что временем их жизни управляет контейнер:
shared-сервис — один экземпляр на весь воркер (реализуйте Resettable, чтобы сбрасывать
состояние запроса), transient — новый экземпляр на каждый запрос. Пару [класс, аргументы]
(её записывает #[MiddlewareWith], она же допустима в HttpConfig) конвейер собирает один раз:
именованные аргументы в конструктор, остальное автовайрится. Экземпляр конвейера не хранит
состояние запроса и переиспользуется между запросами.
Аргументы хэндлера¶
ArgumentResolver заполняет параметры метода контроллера, для каждого параметра в этом порядке:
- тип
ServerRequestInterface,RouteMatch,RouteилиInput— сам запрос, результат роутинга или читатель запроса; #[FromQuery]— DTO гидратируется из всех query-параметров; скаляр читается из параметра с именем аргумента (отсутствие без дефолта — 400);#[FromBody]— JSON (application/json,*+json) илиgetParsedBody(); DTO гидратируется,arrayполучает тело как есть; битый JSON — 400. На скалярном параметре (string,int, ...) атрибут — ошибка конфигурации:LogicExceptionна первом запросе;#[FromHeader('X-Request-Id')]и#[FromCookie](cookie с именем параметра или#[FromCookie('theme')]) заполняют скаляр так же: приведение к типу (400 при несоответствии), дефолт илиnullпри отсутствии, иначе 400;#[FromFile]привязывает загрузку, см. Файловое хранилище;- плейсхолдер маршрута с именем параметра, приведённый к типу (
int,float,bool,string, enum); несоответствие — 400; - сервис из контейнера по типу (
has()); - значение по умолчанию или
nullдля nullable; - иначе
LogicException(это ошибка конфигурации, ответ 500).
Input¶
Keel\Http\Input, внедряемый по типу, — удобное чтение PSR-7-запроса для одиночных параметров
и для middleware; DTO с #[FromQuery] и #[FromBody] остаются типизированным входом.
#[Post('/search')]
public function search(Input $input): array
{
$q = $input->string('q', ''); // query и тело слиты, тело побеждает
$page = $input->int('page', 1); // приведение как в гидраторе; "abc" — 400
$city = $input->get('address.city'); // пути через точку во вложенные данные
$tags = $input->array('tags', []);
$token = $input->bearerToken();
$ip = $input->ip(); // из TrustedProxiesMiddleware, иначе REMOTE_ADDR
// ...
}
all(), get($key, $default), has(), filled() (не null, "" и []), only(), except(),
query(), string()/int()/float()/bool()/array() с дефолтом для отсутствующего ключа,
param($name) (плейсхолдер маршрута), header(), cookie(), file()/files() (загрузки),
bearerToken(), ip(), method(), path(), isSecure(), isJson() (JSON-тело),
wantsJson() (JSON первым в Accept или JSON-тело без Accept), isAjax() и request() для
всего остального. Тело читается при первом обращении и только для методов кроме GET и HEAD;
битый JSON — 400.
Гидратор¶
Hydrator::hydrate(Dto::class, array $data) вызывает конструктор DTO с именованными
значениями: приводит скаляры ('31' → 31, 'true' → true), enum по значению или имени
кейса, вложенные DTO из массивов, union-типы перебором, подставляет дефолты и null.
Все ошибки собираются и отдаются одним BadRequestException с полем errors:
["email: field is required", "address.zip: must be a string"].
Конструктор DTO может отвергнуть сочетание значений, которое не выразить одним правилом, бросив
InvalidArgumentException или DomainException: сообщение станет ещё одной ошибкой, для
вложенного DTO — с префиксом пути поля (range: end must not be before start). Любое другое
исключение из конструктора — ошибка в самом DTO, и ответ будет 500.
JSON-тело декодируется с лимитом вложенности PHP по умолчанию — 512 уровней; документ глубже —
это 400 («Maximum stack depth exceeded»). Размер тела фреймворк не ограничивает: задайте
post_max_size, client_max_body_size или аналог вашего сервера перед ним.
Целые должны помещаться в int: "99999999999999999999" или 1e20 — это «must be an
integer», а не молча обрезанное значение. Члены union перебираются в фиксированном порядке:
int, float, bool, string, затем классы и enum — независимо от порядка объявления (сам PHP
отражает int|string как string|int): для int|string значение "42" станет 42, а "x"
останется строкой.
Пагинация¶
#[FromQuery] PageRequest $page читает ?page=3&perPage=50 (по умолчанию 1 и 20, perPage не
больше 100; выход за границы — 400, как у любого query-DTO); offset() и limit() идут в запрос
к хранилищу. Верните Page, и ответом будет конверт
{"items": [...], "page": 3, "perPage": 50, "total": 93, "pages": 2}:
/** @return Page<UserView> */
#[Get('/users')]
public function list(#[FromQuery] PageRequest $page): Page
{
return Page::of($this->users->slice($page->offset(), $page->limit()), $page, $this->users->count());
}
@return Page<UserView> (или #[Response(200, type: Page::class, items: UserView::class)]) даёт
документу OpenAPI тип элементов. Чтобы изменить умолчания или верхнюю границу, унаследуйте
PageRequest со своим конструктором.
Ответы¶
Responder::respond($result, ?int $status):
| Возврат хэндлера | Ответ |
|---|---|
ResponseInterface |
как есть (статус заменяется, если задан #[Status]) |
null |
204 без тела |
string |
text/html; charset=utf-8 |
Keel\Http\Redirect |
3xx с Location: Redirect::to('/login'), Redirect::route('users.show', ['id' => 5], 303), см. Роутинг |
Keel\Http\EventStream |
text/event-stream, формируется по ходу отправки, см. Стриминг |
массив, JsonSerializable, объект |
application/json, флаги JSON_UNESCAPED_UNICODE, JSON_UNESCAPED_SLASHES, JSON_PRESERVE_ZERO_FRACTION |
Стриминг и Server-Sent Events¶
#[Get('/orders/{id}/progress')]
public function progress(int $id): EventStream
{
return new EventStream(function () use ($id) {
foreach ($this->tracker->follow($id) as $step) {
yield new SseEvent($step, event: 'progress', id: (string) $step->sequence);
}
yield new SseEvent('done', event: 'end');
});
}
#[Get('/orders/export')]
public function export(Responder $responder): ResponseInterface
{
return $responder->stream(function () {
foreach ($this->orders->cursor() as $order) {
yield json_encode($order) . "\n";
}
}, 'application/x-ndjson');
}
Тело ответа — IteratorStream: ничего не выполняется, пока эмиттер его не читает, и каждый
кусок уходит клиенту сразу, как только генератор его выдал. Элементы EventStream — объекты
SseEvent (data, event, id, retry, comment), строки (уходят как data) или любое
значение, которое кодируется в JSON; SseEvent::comment('ping') — обычный heartbeat, чтобы
прокси не закрывали простаивающее соединение. Поток событий отдаётся с Cache-Control: no-cache
и X-Accel-Buffering: no, чтобы nginx пропускал события, а не буферизовал их.
SapiEmitterсбрасывает буферы вывода PHP после каждого куска, а когдаconnection_aborted()сообщает, что клиента нет, останавливается и закрывает тело — генератор завершается (егоfinallyвыполняется). Держитеzlib.output_compressionвыключенным и проверьте, что прокси не буферизует.RoadRunnerRuntimeотправляет неперематываемое тело как поток RoadRunner, по кадру на кусок (RoadRunner 2023.3 или новее); любое другое тело по-прежнему уходит одним кадром.- Строка статуса и заголовки отправлены до запуска генератора: брошенное им исключение рантайм логирует как critical, а ответ обрывается. Ловите что можно внутри.
- Открытый поток занимает воркер (дочерний процесс php-fpm, поток FrankenPHP, воркер RoadRunner)
до своего конца. Рассчитывайте пул на ожидаемое число потоков и оставляйте запас для обычных
запросов через
LoadSheddingMiddleware(maxInFlight, см. Рантаймы). HttpCacheMiddleware,#[OutputCache]и ключи идемпотентности потоковые тела не трогают.
Ошибки: RFC 9457 Problem Details¶
ExceptionHandlerMiddleware стоит первым и превращает любое исключение в
application/problem+json:
{"type":"about:blank","title":"Not Found","status":404,"detail":"No route for GET /nope"}
Keel\Http\Exception\HttpExceptionи наследники (BadRequest,Unauthorized,Forbidden,NotFound,MethodNotAllowed,Conflict,ContentTooLarge,UnsupportedMediaType,UnprocessableContent,TooManyRequests,ServiceUnavailable) задают статус, заголовки (напримерAllow,WWW-Authenticate) и безопасное для клиента сообщение вdetail;errorsдобавляется, если есть.- Любое другое исключение — 500 с
title«Internal Server Error», безdetail; оно логируется черезLoggerInterfaceиз контейнера, если он зарегистрирован. - В
debugдобавляетсяdetailи объектexceptionс классом, файлом, строкой и трейсом. - Если клиент по
Acceptпредпочитаетtext/htmlJSON-у (вышеq, а при равномq— указан раньше), вместо JSON отдаётся HTML-страница: в prod только статус и заголовок, в debug — цепочка исключений, трейс, запрос с замаскированнымиAuthorization/Cookieи кнопка «Copy as Markdown» для баг-репорта. - Тело кодируется с
ProblemDetails::JSON_FLAGS(Responder::JSON_FLAGSплюсJSON_INVALID_UTF8_SUBSTITUTE), поэтому сообщение с битыми байтами не ломает ответ об ошибке. Если рендер всё же упал (например,HttpExceptionс заголовком, который PSR-7 не принимает), сбой логируется какcritical, а клиент получает минимальный problem{"status":500}.
Валидация¶
Атрибуты Keel\Validation\* на параметрах конструктора DTO проверяются гидратором после
приведения типов, ошибки попадают в тот же список errors:
final readonly class SignUp
{
public function __construct(
#[NotBlank, Length(min: 2, max: 50)] public string $name,
#[Email] public string $email,
#[Range(min: 18)] public int $age,
#[Pattern('/^[a-z0-9-]+$/', 'must be a slug')] public string $slug = '',
#[Choice(['free', 'pro'])] public string $plan = 'free',
#[Count(max: 5)] public array $tags = [],
#[Url(schemes: ['https'])] public ?string $site = null,
) {}
}
Правила: NotBlank, Length, Range, Count, Pattern, Alnum(allow: '-_'), Email, Url,
Uuid, Ip(version: 4|6), Hostname, Date(format: 'Y-m-d'), Json, Timezone, Choice,
Unique (без повторяющихся элементов) и Each(new NotBlank(), new Length(max: 30)), которое
прогоняет свои правила по каждому элементу массива и сообщает о первом неподходящем как
tags: [2]: must not be blank. Правило применяется только к переданным полям; null проверяет лишь NotBlank; при ошибке типа правила
поля не запускаются. Своё правило — класс-атрибут, реализующий Keel\Validation\Rule::check().
Встроенные middleware¶
Подключаются списком в HttpConfig, конфиги — обычные объекты в config/:
| Middleware | Конфиг | Что делает |
|---|---|---|
CorsMiddleware |
CorsConfig |
отвечает на preflight (204) без роутера, добавляет Access-Control-*; origin точный, * или шаблон https://*.example.com; allowCredentials требует явного списка origin (* отклоняется в конструкторе) |
RequestIdMiddleware |
— | берёт корректный X-Request-Id или генерирует; атрибут request_id и заголовок ответа |
TrustedProxiesMiddleware |
TrustedProxiesConfig |
применяет X-Forwarded-For/Proto/Host/Port только от доверенных CIDR; IP клиента в атрибуте client_ip |
HttpCacheMiddleware |
HttpCacheConfig |
Cache-Control и ETag для ответов 200 на GET/HEAD, 304 по If-None-Match / If-Modified-Since; см. ниже |
CsrfMiddleware |
CsrfConfig |
synchronizer-токен поверх сессии, см. Сессии |
SecurityHeadersMiddleware |
SecurityHeadersConfig |
HSTS (только HTTPS), CSP, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, COOP и CORP; см. ниже |
TrustedHostsMiddleware |
TrustedHostsConfig |
400 для хоста вне белого списка; см. ниже |
BodyLimitMiddleware |
BodyLimitConfig |
413 сверх лимита размера, 415 для неразрешённого media type; #[BodyLimit] на маршрут |
ServerTimingMiddleware |
ServerTimingConfig |
Server-Timing с общим временем и замерами хэндлеров, по желанию X-Response-Time |
IdempotencyMiddleware |
IdempotencyConfig |
сохранённый ответ на повтор с тем же Idempotency-Key; #[Idempotent] на маршрут |
LoadSheddingMiddleware |
LoadSheddingConfig |
503 с Retry-After при перегрузке, см. Рантаймы |
MethodOverrideMiddleware |
— | POST + X-HTTP-Method-Override → PUT/PATCH/DELETE |
AuthMiddleware |
AuthConfig |
аутентифицирует запросы с учётными данными, см. Аутентификация |
HealthMiddleware |
HealthConfig |
/health и /health/live, см. Здоровье и метрики |
MetricsMiddleware |
MetricsConfig |
метрики Prometheus и /metrics |
OpenApiMiddleware |
OpenApiConfig |
/openapi.json и /docs, см. OpenAPI |
Атрибуты маршрута добавляют свои middleware: #[Deprecated] — DeprecationMiddleware (ниже),
#[OutputCache] — OutputCacheMiddleware (см. Кэш).
TrustedProxiesMiddleware берёт самый правый адрес X-Forwarded-For, который сам не является
доверенным прокси. Если доверены все (как при proxies: ['*']), берётся самый правый валидный
адрес — его дописал ближайший к нам прокси; самый левый, который прислал клиент, не берётся
никогда. Доверенный X-Forwarded-Proto, меняющий схему, заодно сбрасывает порт слушающего
сокета, если X-Forwarded-Port или порт в X-Forwarded-Host не задают другой.
Порядок в списке важен. Рабочий вариант для API за прокси:
return new HttpConfig([
TrustedProxiesMiddleware::class, // схема, хост и IP клиента такими, какими их видел клиент
HealthMiddleware::class, // пробы: любой Host, никогда не сбрасываются
MetricsMiddleware::class,
LoadSheddingMiddleware::class,
TrustedHostsMiddleware::class,
ServerTimingMiddleware::class, // меряет всё, что ниже
OpenApiMiddleware::class, // до строгого CSP: его HTML-страница грузит скрипт
SecurityHeadersMiddleware::class,
CorsMiddleware::class,
BodyLimitMiddleware::class,
RequestIdMiddleware::class,
AuthMiddleware::class,
IdempotencyMiddleware::class, // после аутентификации: ключи привязаны к principal
]);
HTTP-кэширование¶
HttpCacheMiddleware работает с ответами 200 на GET и HEAD. Он добавляет настроенный
Cache-Control, если хэндлер его не задал, ETag — хэш тела, если хэндлер не задал свой
(etag: false отключает), и отвечает пустым 304 с сохранением валидаторов и кэш-заголовков на
запрос, чей If-None-Match совпал с ETag (слабое сравнение, списки и * понимаются) или чей
If-Modified-Since не старше Last-Modified ответа. Хэндлер при этом выполняется: экономится
передача, а не работа.
// config/http_cache.php: умолчания для глобального middleware
return new HttpCacheConfig(etag: true);
// на маршрут или контроллер
#[Get('/catalog')]
#[CacheControl(maxAge: 300, public: true)] // public, max-age=300 + ETag
public function catalog(): array { /* ... */ }
#[Get('/me')]
#[CacheControl(noStore: true)] // no-store, без ETag
public function me(Principal $user): array { /* ... */ }
#[CacheControl(maxAge, sharedMaxAge, public, mustRevalidate, immutable, noStore, etag)]
добавляет middleware к маршруту со своими настройками: работает и без глобального middleware,
а при обоих сразу перекрывает HttpCacheConfig. Глобальный middleware выполняется до роутинга
и видит только конфиг; настройки на маршрут даёт именно атрибут. Хэширование читает всё тело,
поэтому для больших выгрузок и потоков отключайте etag (неперематываемое тело пропускается
и так).
Заголовки безопасности¶
// config/security_headers.php
return SecurityHeadersConfig::api(); // только JSON: CSP "default-src 'none'; frame-ancestors 'none'"
return new SecurityHeadersConfig(
contentSecurityPolicy: "default-src 'self'; img-src 'self' data:",
permissionsPolicy: ['camera' => [], 'geolocation' => ['self']], // camera=(), geolocation=(self)
);
По умолчанию: Strict-Transport-Security: max-age=31536000; includeSubDomains только на
HTTPS-запросах (по обычному HTTP браузеры его игнорируют; за прокси, снимающим TLS, схему даёт
TrustedProxiesMiddleware), X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
Referrer-Policy: strict-origin-when-cross-origin и Cross-Origin-Opener-Policy: same-origin.
CSP (можно в режиме report-only), Permissions-Policy и Cross-Origin-Resource-Policy выключены,
пока не заданы; hstsPreload включайте только после подачи домена в preload-список. Заголовок,
который в ответе уже есть, не трогается, так что отдельная страница (HTML-отчёт, справочник API)
может отдать свою политику. X-Powered-By добавляет сам PHP: выставьте expose_php = Off.
Доверенные хосты¶
// config/trusted_hosts.php
return new TrustedHostsConfig(['api.example.com', '*.example.com']);
Запрос к любому другому хосту получает 400 ещё до роутинга, поэтому подделанный Host не
попадает в генерируемые URL, редиректы и ссылки сброса пароля. *.example.com покрывает
поддомены любой глубины, но не сам example.com; IP-литералы (10.0.0.5, [::1]) и *
допустимы. Хост берётся из URI запроса — после того как TrustedProxiesMiddleware применил
доверенный X-Forwarded-Host. Пробам, которые обращаются к поду по IP, нужен этот IP в списке
или HealthMiddleware раньше в HttpConfig.
Лимиты тела запроса¶
// config/body_limit.php: каждый запрос с телом
return new BodyLimitConfig(maxBytes: '1M', contentTypes: ['application/json', 'application/*+json']);
#[Post('/avatars')]
#[BodyLimit('20M', contentTypes: ['multipart/form-data', 'image/*'])]
public function upload(#[FromFile] UploadedFileInterface $avatar): array { /* ... */ }
Content-Length сверх лимита получает 413 до чтения тела; тело без него (chunked) оборачивается
так, что чтение за лимит бросает 413, а не забивает память воркера. Тело, чей media type не
подходит ни под один шаблон (точный, type/* или type/*+suffix), получает 415. Запросы без
тела проходят как есть. Глобальный middleware ещё до роутинга дешёвым матчингом читает
#[BodyLimit] маршрута, поэтому маршрут может поднять лимит выше глобального; аргумент null
оставляет глобальное значение. Атрибут работает и без глобального middleware, а документ OpenAPI
перечисляет для маршрута 413 и 415. Собственные лимиты сервера срабатывают раньше:
post_max_size в php-fpm, http.max_request_size в RoadRunner, request_body в Caddy.
Ключи идемпотентности¶
// config/idempotency.php; нужен PSR-16 кэш (CacheProvider)
return new IdempotencyConfig(ttl: 86_400);
#[Post('/payments')]
#[Idempotent(required: true)]
public function pay(#[FromBody] Payment $payment): array { /* ... */ }
Клиент отправляет небезопасный запрос с Idempotency-Key: <uuid>
(draft-ietf-httpapi-idempotency-key-header). Первый запрос выполняется, и его ответ (любой статус
ниже 500, кроме 429) сохраняется на ttl секунд. Повтор с тем же ключом и теми же методом,
путём, query и телом получает этот ответ с пометкой Idempotent-Replayed: true без запуска
хэндлера; тот же ключ с другим запросом получает 422; повтор, пока первый ещё выполняется, —
409 с Retry-After. Запрос, который упал (5xx или исключение), освобождает ключ, и повтор
выполняется заново. Ключ — 1-255 видимых ASCII-символов (строка structured field в кавычках тоже
принимается); ключи привязаны к principal, а без него — к заголовку Authorization: ставьте
middleware после AuthMiddleware.
Блокировка — атомарный инкремент CounterStorage: APCu, Redis (RedisConfig(use: ['counters']))
или, без них, память процесса, которая защищает только один воркер. Глобальный middleware
покрывает POST и PATCH (IdempotencyConfig::$methods) с ключом и учитывает #[Idempotent]
маршрута; атрибут сам по себе действует на маршрут с любым методом и документирует в OpenAPI
заголовок, 409 и 422. Потоковые ответы не сохраняются.
Server-Timing¶
// config/server_timing.php
return new ServerTimingConfig(responseTimeHeader: 'X-Response-Time');
#[Get('/users/{id}')]
public function show(int $id, ServerTiming $timing): UserView
{
$user = $timing->measure('db', fn() => $this->users->find($id), 'User lookup');
$timing->add('cache', 0.4);
return UserView::from($user);
}
ServerTimingMiddleware меряет всё, что ниже него, и отвечает
Server-Timing: db;dur=3.2;desc="User lookup", cache;dur=0.4, total;dur=7.9 — браузеры показывают
это в панели сети. ServerTiming — свежий сборщик на каждый запрос, хранится атрибутом запроса и
внедряется по типу (ServerTiming::of($request) в middleware); start()/stop(), measure() и
add() записывают миллисекунды. timingAllowOrigin разрешает страницам других origin читать
цифры, metrics: false оставляет только общее время. Тайминги рассказывают любому, сколько
отвечают ваши бэкенды: подумайте, прежде чем открывать их в публичном API.
Устаревшие маршруты¶
#[Get('/v1/users')]
#[Deprecated(since: '2026-07-01', sunset: '2027-01-01', link: 'https://docs.example.com/v2', successor: 'https://api.example.com/v2/users')]
public function list(): array { /* ... */ }
Ответы маршрута несут Deprecation: @1782864000 (RFC 9745), Sunset: Fri, 01 Jan 2027 00:00:00
GMT (RFC 8594), Link: <...>; rel="deprecation" и Link: <...>; rel="successor-version"; без
since маршрут объявляется устаревшим уже сейчас (@0). Операция OpenAPI получает
deprecated: true и x-sunset, а keel:routes помечает хэндлер. Даты читаются в UTC; sunset
раньше since или URL, который не помещается в заголовок Link, — ошибка при компиляции
маршрутов. Ошибки, брошенные хэндлером, отдаются без этих заголовков. Не путать с атрибутом
самого PHP #[\Deprecated], из-за которого вызов метода порождает E_USER_DEPRECATED.
Прочее¶
MethodOverrideMiddleware— опциональный; подменяет метод только уPOSTи только наPUT/PATCH/DELETEпо заголовкуX-HTTP-Method-Override.SapiEmitterотправляет ответ через SAPI (php-fpm, встроенный сервер); пример фронт-контроллера —examples/hello/public/index.php. Неперематываемое тело стримится, см. выше.SapiEmitter::finish()завершает ответ для клиента, а скрипт продолжает работу (fastcgi_finish_request(),frankenphp_finish_request());FpmRuntimeвызывает его передKernel::terminate().