Основы веб-разработки

Куки

Кука — маленькое именованное значение, которое сервер отдаёт браузеру заголовком Set-Cookie, а браузер потом сам возвращает заголовком Cookie в каждом запросе к этому сайту. Это единственный способ, которым HTTP — протокол без памяти — узнаёт, что два запроса пришли от одного человека.

Чтение Cookie::get()Запись Cookie::add()Значение SetCookie

Что это и зачем

Проблема. HTTP не помнит ничего. Пользователь ввёл логин, следующий запрос приходит как от незнакомца. Всё, чем сервер располагает между запросами, — это то, что он сам попросил браузер сохранить и вернуть.

Решение. Сервер отдаёт куку, браузер хранит её и прикладывает к каждому следующему запросу. На этом стоят сессии, «запомнить меня», выбранная тема и язык, корзина до регистрации, CSRF-токены.

Сложность не в самом обмене — он тривиален, — а в атрибутах. Кука без HttpOnly читается посторонним скриптом, без SameSite уезжает на чужой сайт вместе с запросом, который подделал третий, без Secure идёт по открытому каналу. Ошибка в любом из них не даёт ни исключения, ни предупреждения: браузер просто ведёт себя не так, как вы ожидали. Поэтому слой куки в Winter — это в первую очередь объект, который отказывается собираться неправильно.

Один заголовок, много значений

Set-Cookie — единственный заголовок HTTP, который законно повторяется: три куки — три заголовка. Поэтому у кук отдельный путь до ответа, а не ->header('Set-Cookie', ...): карта заголовков ключуется по имени и вторая кука затёрла бы первую.

Быстрый старт

main/Controller/SessionController.php
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()

php
Cookie::get('sid');     // 'abc123'
Cookie::get('unknown'); // null
Аргумент Тип Назначение
$name string Имя куки. Регистр важен — так его прислал клиент.

Возвращает string или null, если такой куки в запросе не было.

Cookie::has()

php
Cookie::has('consent');  // true — даже если значение пустое

Отличается от get() !== null ровно одним случаем: consent= — это присланная кука с пустым значением, и has() скажет true, а не спутает её с отсутствующей. Для согласий и флагов разница существенная.

Cookie::all()

php
Cookie::all();   // ['sid' => 'abc123', 'theme' => 'dark']

Все куки запроса в том порядке, в каком их прислал клиент.

Через объект запроса

Те же данные доступны прямо на HttpRequest, если запрос уже инжектирован в метод:

php
#[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()

php
Cookie::make('sid', $token);
Аргумент Тип По умолчанию Назначение
$name string Имя куки
$value string '' Значение; кодируется при отправке

Отличается от SetCookie::make() двумя вещами: проставляет Secure, если запрос пришёл по HTTPS, и применяет умолчания приложения. Это тот вариант, который нужен прикладному коду.

SetCookie::make()

php
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()

php
SetCookie::forget('sid');
SetCookie::forget('sid', '/admin', 'example.com');
Аргумент Тип По умолчанию Назначение
$name string Какую куку удалить
$path string / Путь, с которым она была поставлена
$domain ?string null Домен, с которым она была поставлена

Собирает куку с пустым значением и сроком в прошлом:

text
sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=Lax

Путь и домен обязаны совпасть

Для браузера путь и домен — часть личности куки. Кука, поставленная на /admin, не удаляется удалением на /: браузер видит другую куку, а исходная остаётся жить.

Это самая частая причина «разлогинивание не работает».

Срок жизни

expiresIn()

php
Cookie::make('sid', $token)->expiresIn(3600);      // час
Cookie::make('remember', $t)->expiresIn(60 * 60 * 24 * 30);  // месяц
Аргумент Тип Назначение
$seconds int Сколько жить, начиная с текущего момента

Отдаётся так:

text
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()

php
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()

php
Cookie::make('csrf', $token)->session();

Снимает срок вовсе: кука живёт до закрытия браузера. Умолчание, и оно же то, чего вы хотите от CSRF-токена — незачем ему переживать окно.

Область видимости

path()

php
Cookie::make('admin_pref', '1')->path('/admin');
Аргумент Тип По умолчанию Назначение
$path string / Префикс URL, для которого кука отправляется

Браузер приложит куку к /admin и /admin/users, но не к /. Сужение пути — не защита (любая страница сайта всё равно может сходить на /admin), а способ не таскать лишнее в каждом запросе.

domain()

php
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()

php
Cookie::make('sid', $token)->httpOnly();        // включён по умолчанию
Cookie::make('theme', 'dark')->httpOnly(false); // читаемая скриптом
Аргумент Тип По умолчанию Назначение
$httpOnly bool true Скрыть куку от document.cookie

Включён по умолчанию. Выключайте только для значения, которое действительно читает JavaScript самой страницы — тема, свёрнутая панель, номер шага мастера. Токен сессии таким значением не бывает: с HttpOnly найденная на странице XSS не сможет его вынести.

secure()

php
Cookie::make('sid', $token)->secure();       // Cookie::make() уже сделал это на https
SetCookie::make('sid', $token)->secure();
Аргумент Тип По умолчанию Назначение
$secure bool true Отправлять только по HTTPS

sameSite()

php
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()

php
Cookie::make('widget', $t)->secure()->sameSite(SameSite::None)->partitioned();
Аргумент Тип По умолчанию Назначение
$partitioned bool true Отдельная банка кук на каждый встраивающий сайт

CHIPS: виджет, встроенный в a.com и в b.com, получает две независимые куки и не может по ним связать одного пользователя между сайтами. Требует Secure.

Значение

raw()

php
Cookie::make('jwt', $jwt)->raw();
Аргумент Тип По умолчанию Назначение
$raw bool true Отдать значение как есть, без URL-кодирования

По умолчанию значение кодируется: a b/c уезжает как a%20b%2Fc и возвращается обратно раскодированным. Отключать это стоит для значений, которые и так безопасны, — JWT, hex-дайджест, — чтобы промежуточное звено не закодировало их второй раз.

Сырое значение проверяется: пробел, кавычка, запятая, точка с запятой и управляющие символы приведут к исключению. Без проверки точка с запятой оборвала бы куку, а хвост браузер прочитал бы как атрибуты.

value()

php
$template = SetCookie::make('sid')->secure()->sameSite(SameSite::Strict)->expiresIn(3600);

$forAlice = $template->value($aliceToken);
$forBob   = $template->value($bobToken);
Аргумент Тип Назначение
$value string Новое значение; атрибуты сохраняются

Ради этого объект и неизменяем: заготовку можно раздавать, не боясь, что кто-то поменяет её под остальными.

Отправка

Cookie::add()

php
Cookie::add(Cookie::make('sid', $token)->expiresIn(3600));
Аргумент Тип Назначение
$cookie SetCookie Что отправить

Кука уходит в ответ сразу, ровно как заголовок, — она не копится до конца обработки. Это важнее, чем кажется:

php
Cookie::forget('sid');                       // сессия погашена
throw new ResponseException('Unauthorized', HttpCode::UNAUTHORIZED);

Если бы куки сливались в ответ на успешном пути, эта — самая нужная — потерялась бы именно тогда, когда запрос упал, и браузер остался бы с мёртвой сессией.

Вне запроса add() бросает LogicException: писать некуда, а молча проглоченная кука выглядит как «браузер её проигнорировал» — отлаживать это заметно дороже.

Cookie::forget()

php
Cookie::forget('sid');
Cookie::forget('sid', '/admin', 'example.com');

Короткая запись для Cookie::add(SetCookie::forget(...)). Аргументы те же, и требование совпадения пути и домена то же.

ResponseEntity::cookie()

php
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:

php
return ResponseView::view('dashboard', ['user' => $user])
  ->cookie(Cookie::make('sid', $token)->expiresIn(3600));

Умолчания приложения

Cookie::defaults()

main/Application.php
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 по схеме, поэтому приложение может перекрыть и её:

php
// За прокси, который терминирует TLS и не передаёт X-Forwarded-Proto
Cookie::defaults(fn(SetCookie $c) => $c->secure());

На SetCookie::make() умолчания не действуют — та версия остаётся чистой.


Что не соберётся

Объект отказывается отдавать заведомо нерабочую куку — до того, как браузер молча её выбросит:

Ситуация Что произойдёт
SameSite=None без Secure InvalidArgumentException — браузер выбросил бы куку
Partitioned без Secure InvalidArgumentException
raw() со значением, где пробел, ;, ,, кавычка InvalidArgumentException
Имя с пробелом, =, ;, ,, скобками, слэшем InvalidArgumentException при сборке
Пустое имя InvalidArgumentException
text
Cookie 'sid': SameSite=None requires Secure, or the browser discards the cookie.

Порядок вызовов при этом не важен: ->sameSite(None)->secure() и ->secure()->sameSite(None) одинаково допустимы — проверка выполняется при сборке заголовка, а не в каждом сеттере.

Слой не пользуется ни разбором PHP, ни разбором Swoole, и на то есть измеренные причины.

На чтении PHP переименовывает имена. Один и тот же запрос:

text
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() пишет атрибуты своим регистром и кодирует пробел как +:

text
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=Lax

Winter собирает строку сам и отдаёт её обоим рантаймам дословно — Swoole и FPM отправляют байт в байт одно и то же, и тесты, проверяющие эти байты, что-то значат.

Чего здесь нет

Ни подписи, ни шифрования значения. Это осознанно: ядро даёт механизм, политику выбирает разработчик. Нужна подпись — считайте HMAC от значения перед make() и проверяйте после get(); нужен непрозрачный идентификатор — храните данные у себя, а в куке держите только ключ.

Сессии — отдельный слой поверх кук, не часть этой страницы.

Дальше

  • ОтветыResponseEntity, коды и заголовки
  • Запросы — что ещё приходит вместе с куками
  • Middleware — где удобно ставить и гасить сессию