Куки
Кука — маленькое именованное значение, которое сервер отдаёт браузеру заголовком
Set-Cookie, а браузер потом сам возвращает заголовком Cookie в
каждом запросе к этому сайту. Это единственный способ, которым HTTP — протокол без
памяти — узнаёт, что два запроса пришли от одного человека.
Что это и зачем
Проблема. HTTP не помнит ничего. Пользователь ввёл логин, следующий запрос приходит как от незнакомца. Всё, чем сервер располагает между запросами, — это то, что он сам попросил браузер сохранить и вернуть.
Решение. Сервер отдаёт куку, браузер хранит её и прикладывает к каждому следующему запросу. На этом стоят сессии, «запомнить меня», выбранная тема и язык, корзина до регистрации, CSRF-токены.
Сложность не в самом обмене — он тривиален, — а в атрибутах. Кука без HttpOnly
читается посторонним скриптом, без SameSite уезжает на чужой сайт вместе с запросом,
который подделал третий, без Secure идёт по открытому каналу. Ошибка в любом из них не
даёт ни исключения, ни предупреждения: браузер просто ведёт себя не так, как вы ожидали.
Поэтому слой куки в Winter — это в первую очередь объект, который отказывается
собираться неправильно.
Один заголовок, много значений
Set-Cookie — единственный заголовок HTTP, который законно повторяется: три куки — три
заголовка. Поэтому у кук отдельный путь до ответа, а не ->header('Set-Cookie', ...):
карта заголовков ключуется по имени и вторая кука затёрла бы первую.
Быстрый старт
use Flytachi\Winter\Kernel\Http\Cookie\Cookie;
#[GetMapping('login')]
public function login(): string
{
Cookie::add(Cookie::make('sid', $token)->expiresIn(3600));
return 'ok';
}
#[GetMapping('me')]
public function me(): string
{
return Cookie::get('sid') ?? 'anonymous';
}
#[GetMapping('logout')]
public function logout(): string
{
Cookie::forget('sid');
return 'ok';
}Ничего инициализировать не нужно: роутер вызывает Cookie::init() в начале каждого
запроса, рядом с Header::init().
Справочник
Чтение
Читается то, что прислал браузер. Значения уже раскодированы.
Cookie::get()
Cookie::get('sid'); // 'abc123'
Cookie::get('unknown'); // null| Аргумент | Тип | Назначение |
|---|---|---|
$name |
string |
Имя куки. Регистр важен — так его прислал клиент. |
Возвращает string или null, если такой куки в запросе не было.
Cookie::has()
Cookie::has('consent'); // true — даже если значение пустоеОтличается от get() !== null ровно одним случаем: consent= — это присланная кука
с пустым значением, и has() скажет true, а не спутает её с отсутствующей. Для
согласий и флагов разница существенная.
Cookie::all()
Cookie::all(); // ['sid' => 'abc123', 'theme' => 'dark']Все куки запроса в том порядке, в каком их прислал клиент.
Через объект запроса
Те же данные доступны прямо на HttpRequest, если запрос уже инжектирован в метод:
#[GetMapping('me')]
public function me(HttpRequest $request): string
{
return $request->getCookie('sid') ?? 'anonymous';
}| Метод | Возвращает |
|---|---|
getCookie(string $name) |
?string — как getHeader() |
getCookies() |
array<string, string> — как getHeaders() |
Пара названа по образцу заголовков не случайно: кука читается ровно так же, как заголовок.
Фасад Cookie берёт данные отсюда же — разница только в том, что ему не нужен объект
запроса под рукой.
Почему не массив объектов, как в Java
HttpServletRequest::getCookies() отдаёт Cookie[], но во входящем запросе браузер
присылает только пары имя=значение — ни Path, ни Domain, ни Max-Age там нет. Java
всё равно возвращает объекты с пустыми полями, и на этом регулярно обжигаются, пытаясь
прочитать срок жизни присланной куки.
Карта строк не обещает того, чего в запросе не бывает. Атрибуты — это про SetCookie,
то есть про исходящую сторону.
Создание
Кука описывается объектом SetCookie — неизменяемым: каждый метод возвращает новый
экземпляр, поэтому общий заготовленный объект нельзя испортить из другого места.
Cookie::make()
Cookie::make('sid', $token);| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$name |
string |
— | Имя куки |
$value |
string |
'' |
Значение; кодируется при отправке |
Отличается от SetCookie::make() двумя вещами: проставляет Secure, если запрос пришёл
по HTTPS, и применяет умолчания приложения. Это тот вариант,
который нужен прикладному коду.
SetCookie::make()
use Flytachi\Winter\Kernel\Http\Cookie\SetCookie;
SetCookie::make('theme', 'dark');Чистая версия: ничего не знает ни о запросе, ни о настройках приложения. Нужна там, где запроса нет — в тестах, в фоновой задаче, при сборке заготовки.
Умолчания у обеих одинаковые:
| Атрибут | Значение | Почему так |
|---|---|---|
Path |
/ |
Кука видна всему сайту |
HttpOnly |
включён | JavaScript её не прочитает |
SameSite |
Lax |
То же, что подставляют современные браузеры |
| Срок жизни | сессия | Умирает вместе с окном браузера |
Secure |
выключен | См. ниже |
Почему `Secure` не входит в умолчания `SetCookie`
Объект-значение не видит схему запроса, а кука с Secure, отданная по обычному HTTP,
браузером молча выбрасывается. Ошибиться здесь можно в обе стороны, и обе тихие.
Поэтому схему подставляет Cookie::make() — у него есть живой запрос. Если приложение
стоит за прокси, который терминирует TLS, схему возьмут из X-Forwarded-Proto; если
прокси её не передаёт, поправьте через умолчания.
SetCookie::forget()
SetCookie::forget('sid');
SetCookie::forget('sid', '/admin', 'example.com');| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$name |
string |
— | Какую куку удалить |
$path |
string |
/ |
Путь, с которым она была поставлена |
$domain |
?string |
null |
Домен, с которым она была поставлена |
Собирает куку с пустым значением и сроком в прошлом:
sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=LaxПуть и домен обязаны совпасть
Для браузера путь и домен — часть личности куки. Кука, поставленная на /admin, не
удаляется удалением на /: браузер видит другую куку, а исходная остаётся жить.
Это самая частая причина «разлогинивание не работает».
Срок жизни
expiresIn()
Cookie::make('sid', $token)->expiresIn(3600); // час
Cookie::make('remember', $t)->expiresIn(60 * 60 * 24 * 30); // месяц| Аргумент | Тип | Назначение |
|---|---|---|
$seconds |
int |
Сколько жить, начиная с текущего момента |
Отдаётся так:
sid=abc; Expires=Tue, 19 Aug 2025 11:40:00 GMT; Max-Age=3600; Path=/; HttpOnly; SameSite=LaxОба атрибута сразу — не избыточность: Max-Age понимают современные браузеры,
Expires — самые старые, а RFC 6265 говорит, что при наличии обоих побеждает Max-Age.
Пара безопасна, а не противоречива.
expiresAt()
Cookie::make('promo', 'x')->expiresAt(new DateTimeImmutable('2026-01-01'));
Cookie::make('promo', 'x')->expiresAt(1767225600);| Аргумент | Тип | Назначение |
|---|---|---|
$moment |
DateTimeInterface|int |
Абсолютный момент или unix-метка |
Для «до конца акции» и «до полуночи» — там, где важна дата, а не длительность. Срок в
прошлом означает удаление, и отрицательный Max-Age при этом не отдаётся (некоторые
клиенты считают его ошибкой разбора) — вместо него Max-Age=0.
session()
Cookie::make('csrf', $token)->session();Снимает срок вовсе: кука живёт до закрытия браузера. Умолчание, и оно же то, чего вы хотите от CSRF-токена — незачем ему переживать окно.
Область видимости
path()
Cookie::make('admin_pref', '1')->path('/admin');| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$path |
string |
/ |
Префикс URL, для которого кука отправляется |
Браузер приложит куку к /admin и /admin/users, но не к /. Сужение пути — не защита
(любая страница сайта всё равно может сходить на /admin), а способ не таскать лишнее в
каждом запросе.
domain()
Cookie::make('sid', $t)->domain('example.com'); // + api.example.com, www.example.com
Cookie::make('sid', $t)->domain(null); // только текущий хост| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$domain |
?string |
null |
Домен; null — только этот хост, без поддоменов |
Без домена кука host-only, и это безопаснее: она не уедет на соседний поддомен, которым может владеть другая команда. Указывайте домен, только когда куку действительно должны видеть несколько поддоменов.
Ведущая точка (.example.com) — наследие: современные браузеры её игнорируют,
example.com уже покрывает поддомены.
Флаги безопасности
httpOnly()
Cookie::make('sid', $token)->httpOnly(); // включён по умолчанию
Cookie::make('theme', 'dark')->httpOnly(false); // читаемая скриптом| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$httpOnly |
bool |
true |
Скрыть куку от document.cookie |
Включён по умолчанию. Выключайте только для значения, которое действительно читает
JavaScript самой страницы — тема, свёрнутая панель, номер шага мастера. Токен сессии
таким значением не бывает: с HttpOnly найденная на странице XSS не сможет его вынести.
secure()
Cookie::make('sid', $token)->secure(); // Cookie::make() уже сделал это на https
SetCookie::make('sid', $token)->secure();| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$secure |
bool |
true |
Отправлять только по HTTPS |
sameSite()
use Flytachi\Winter\Kernel\Http\Cookie\SameSite;
Cookie::make('sid', $t)->sameSite(SameSite::Lax); // умолчание
Cookie::make('sid', $t)->sameSite(SameSite::Strict);
Cookie::make('w', $t)->secure()->sameSite(SameSite::None);
Cookie::make('a', $t)->sameSite(null); // атрибут не отдавать| Значение | Когда браузер приложит куку | Для чего |
|---|---|---|
Lax |
Свои запросы + переходы по ссылке (GET) | Сессия обычного сайта |
Strict |
Только свои запросы | Банк, админка; переход по ссылке извне выглядит как «не вошёл» |
None |
Всегда, включая чужие сайты | Виджет, встроенный в чужую страницу. Требует Secure |
null |
Атрибут не отдаётся | Решает браузер (сегодня — как Lax) |
Это и есть защита от CSRF: без SameSite браузер приложит вашу куку к запросу, который
инициировала чужая страница, и сервер не отличит его от настоящего.
partitioned()
Cookie::make('widget', $t)->secure()->sameSite(SameSite::None)->partitioned();| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$partitioned |
bool |
true |
Отдельная банка кук на каждый встраивающий сайт |
CHIPS: виджет, встроенный в a.com и в b.com, получает две независимые куки и не может
по ним связать одного пользователя между сайтами. Требует Secure.
Значение
raw()
Cookie::make('jwt', $jwt)->raw();| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$raw |
bool |
true |
Отдать значение как есть, без URL-кодирования |
По умолчанию значение кодируется: a b/c уезжает как a%20b%2Fc и возвращается обратно
раскодированным. Отключать это стоит для значений, которые и так безопасны, — JWT,
hex-дайджест, — чтобы промежуточное звено не закодировало их второй раз.
Сырое значение проверяется: пробел, кавычка, запятая, точка с запятой и управляющие символы приведут к исключению. Без проверки точка с запятой оборвала бы куку, а хвост браузер прочитал бы как атрибуты.
value()
$template = SetCookie::make('sid')->secure()->sameSite(SameSite::Strict)->expiresIn(3600);
$forAlice = $template->value($aliceToken);
$forBob = $template->value($bobToken);| Аргумент | Тип | Назначение |
|---|---|---|
$value |
string |
Новое значение; атрибуты сохраняются |
Ради этого объект и неизменяем: заготовку можно раздавать, не боясь, что кто-то поменяет её под остальными.
Отправка
Cookie::add()
Cookie::add(Cookie::make('sid', $token)->expiresIn(3600));| Аргумент | Тип | Назначение |
|---|---|---|
$cookie |
SetCookie |
Что отправить |
Кука уходит в ответ сразу, ровно как заголовок, — она не копится до конца обработки. Это важнее, чем кажется:
Cookie::forget('sid'); // сессия погашена
throw new ResponseException('Unauthorized', HttpCode::UNAUTHORIZED);Если бы куки сливались в ответ на успешном пути, эта — самая нужная — потерялась бы именно тогда, когда запрос упал, и браузер остался бы с мёртвой сессией.
Вне запроса add() бросает LogicException: писать некуда, а молча проглоченная кука
выглядит как «браузер её проигнорировал» — отлаживать это заметно дороже.
Cookie::forget()
Cookie::forget('sid');
Cookie::forget('sid', '/admin', 'example.com');Короткая запись для Cookie::add(SetCookie::forget(...)). Аргументы те же, и требование
совпадения пути и домена то же.
ResponseEntity::cookie()
return ResponseEntity::ok(['ok' => true])
->cookie(SetCookie::make('sid', $token)->expiresIn(3600))
->cookie(SetCookie::make('theme', 'dark')->httpOnly(false));| Аргумент | Тип | Назначение |
|---|---|---|
$cookie |
SetCookie |
Что отправить вместе с этим ответом |
Декларативная дверь: кука — часть возвращаемого ответа, а не побочный эффект. Вызывать можно сколько угодно раз, куки хранятся списком, а не картой по имени.
Оба способа складываются в один ответ, порядок сохраняется.
Остальные типы ответов
->cookie() есть у всех четырёх ответов, которые умеет отдавать контроллер:
| Тип | Пример |
|---|---|
ResponseEntity |
ResponseEntity::ok($data)->cookie($c) |
ResponseView |
ResponseView::view('login')->cookie($c) |
ResponseFile |
ResponseFile::csv($rows, 'report.csv')->cookie($c) |
ResponseStreamFile |
ResponseStreamFile::open($path)->cookie($c) |
Выбор типа ответа — решение о форме: JSON, страница, файл. Он не должен заодно решать, можно ли открыть сессию. Логин, отдающий свёрстанную страницу, ставит куку так же, как логин, отдающий JSON:
return ResponseView::view('dashboard', ['user' => $user])
->cookie(Cookie::make('sid', $token)->expiresIn(3600));Умолчания приложения
Cookie::defaults()
use Flytachi\Winter\Kernel\Http\Cookie\{Cookie, SameSite, SetCookie};
Cookie::defaults(fn(SetCookie $c) => $c
->domain('example.com')
->sameSite(SameSite::Strict));| Аргумент | Тип | Назначение |
|---|---|---|
$configure |
?Closure(SetCookie): SetCookie |
Что применить к каждой куке из Cookie::make(); null сбрасывает |
Настраивается один раз при старте. Это не фиксированная заготовка, а функция, и
разница существенная: она выполняется после подстановки Secure по схеме, поэтому
приложение может перекрыть и её:
// За прокси, который терминирует TLS и не передаёт X-Forwarded-Proto
Cookie::defaults(fn(SetCookie $c) => $c->secure());На SetCookie::make() умолчания не действуют — та версия остаётся чистой.
Что не соберётся
Объект отказывается отдавать заведомо нерабочую куку — до того, как браузер молча её выбросит:
| Ситуация | Что произойдёт |
|---|---|
SameSite=None без Secure |
InvalidArgumentException — браузер выбросил бы куку |
Partitioned без Secure |
InvalidArgumentException |
raw() со значением, где пробел, ;, ,, кавычка |
InvalidArgumentException |
Имя с пробелом, =, ;, ,, скобками, слэшем |
InvalidArgumentException при сборке |
| Пустое имя | InvalidArgumentException |
Cookie 'sid': SameSite=None requires Secure, or the browser discards the cookie.Порядок вызовов при этом не важен: ->sameSite(None)->secure() и
->secure()->sameSite(None) одинаково допустимы — проверка выполняется при сборке
заголовка, а не в каждом сеттере.
Почему не $_COOKIE и не $res->cookie()
Слой не пользуется ни разбором PHP, ни разбором Swoole, и на то есть измеренные причины.
На чтении PHP переименовывает имена. Один и тот же запрос:
Cookie: my.sid=1; my sid=2; ok=3
$_COOKIE → ["my_sid", "ok"] точка переименована, вторая кука выброшена
Winter → ["my.sid", "my sid", "ok"]Swoole разбирает сам и так не корёжит — то есть на $_COOKIE два режима дали бы разные
наборы ключей. Winter разбирает сырой заголовок Cookie в обоих режимах, поэтому имена
совпадают. Всё остальное поведение намеренно повторяет $_COOKIE: из двух одноимённых
кук побеждает первая, пустое значение сохраняется, имя без = читается как пустая
строка.
На записи нативный Swoole\Http\Response::cookie() пишет атрибуты своим регистром и
кодирует пробел как +:
Swoole Set-Cookie: sid=v1; expires=…; Max-Age=0; path=/; secure; HttpOnly; SameSite=Lax
Winter Set-Cookie: sid=v1; Expires=…; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=LaxWinter собирает строку сам и отдаёт её обоим рантаймам дословно — Swoole и FPM отправляют байт в байт одно и то же, и тесты, проверяющие эти байты, что-то значат.
Чего здесь нет
Ни подписи, ни шифрования значения. Это осознанно: ядро даёт механизм, политику выбирает
разработчик. Нужна подпись — считайте HMAC от значения перед make() и проверяйте после
get(); нужен непрозрачный идентификатор — храните данные у себя, а в куке держите
только ключ.
Сессии — отдельный слой поверх кук, не часть этой страницы.
Дальше
- Ответы —
ResponseEntity, коды и заголовки - Запросы — что ещё приходит вместе с куками
- Middleware — где удобно ставить и гасить сессию