Cookies
A cookie is a small named value the server hands to the browser in a Set-Cookie
header, and that the browser then returns in a Cookie header on every request
to that site. It is the only way HTTP — a protocol with no memory — learns that two
requests came from the same person.
What this is and why
The problem. HTTP remembers nothing. A user signs in, and the next request arrives as if from a stranger. All the server has between requests is what it asked the browser to keep and send back.
The solution. The server hands over a cookie, the browser stores it and attaches it to every following request. Sessions, “remember me”, the chosen theme and language, a cart before sign-up, CSRF tokens — all of it rests on this.
The difficulty is not the exchange, which is trivial, but the attributes. A cookie
without HttpOnly can be read by a foreign script, without SameSite it travels along
with a request some third party forged, without Secure it goes over the wire in the
clear. Getting any of them wrong raises no exception and prints no warning: the browser
simply behaves differently than you expected. So Winter’s cookie layer is first of all an
object that refuses to be built incorrectly.
One header, many values
Set-Cookie is the one HTTP header that legitimately repeats: three cookies, three
headers. That is why cookies have their own path to the response rather than
->header('Set-Cookie', ...) — the header map is keyed by name, and a second cookie
would destroy the first.
Quick start
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';
}Nothing needs initialising: the router calls Cookie::init() at the start of every
request, next to Header::init().
Reference
Reading
What you read is what the browser sent. Values arrive already decoded.
Cookie::get()
Cookie::get('sid'); // 'abc123'
Cookie::get('unknown'); // null| Argument | Type | Purpose |
|---|---|---|
$name |
string |
Cookie name. Case matters — it is how the client sent it. |
Returns a string, or null when the request carried no such cookie.
Cookie::has()
Cookie::has('consent'); // true — even when the value is emptyIt differs from get() !== null in exactly one case: consent= is a cookie the client
did send, with an empty value, and has() says true rather than confusing it with
an absent one. For consents and flags that distinction matters.
Cookie::all()
Cookie::all(); // ['sid' => 'abc123', 'theme' => 'dark']Every cookie of the request, in the order the client sent them.
Through the request object
The same data is on HttpRequest, when the request is already injected into the method:
#[GetMapping('me')]
public function me(HttpRequest $request): string
{
return $request->getCookie('sid') ?? 'anonymous';
}| Method | Returns |
|---|---|
getCookie(string $name) |
?string — like getHeader() |
getCookies() |
array<string, string> — like getHeaders() |
The pair is named after the header pair on purpose: a cookie is read exactly the way a
header is. The Cookie facade reads from here too — the only difference is that it does
not need the request object at hand.
Why not an array of objects, as in Java
HttpServletRequest::getCookies() hands back Cookie[], but an incoming request
carries only name=value pairs — there is no Path, Domain or Max-Age in it. Java
returns objects with empty fields anyway, and people regularly burn themselves trying to
read the lifetime of a cookie that was sent to them.
A map of strings promises nothing the request does not contain. Attributes belong to
SetCookie — that is, to the outgoing side.
Building
A cookie is described by a SetCookie — immutable: every method returns a new
instance, so a shared prepared object cannot be spoiled from elsewhere.
Cookie::make()
Cookie::make('sid', $token);| Argument | Type | Default | Purpose |
|---|---|---|---|
$name |
string |
— | Cookie name |
$value |
string |
'' |
Value; encoded on the way out |
It differs from SetCookie::make() in two ways: it sets Secure when the request arrived
over HTTPS, and it applies the application defaults. This is the
one application code wants.
SetCookie::make()
use Flytachi\Winter\Kernel\Http\Cookie\SetCookie;
SetCookie::make('theme', 'dark');The pure version: it knows nothing about the request or the application’s settings. Use it where there is no request — in tests, in a background job, when building a prototype.
Both start from the same defaults:
| Attribute | Value | Why |
|---|---|---|
Path |
/ |
The cookie covers the whole site |
HttpOnly |
on | JavaScript cannot read it |
SameSite |
Lax |
What modern browsers apply anyway |
| Lifetime | session | Dies with the browser window |
Secure |
off | See below |
Why `Secure` is not among `SetCookie`'s defaults
A value object cannot see the request scheme, and a cookie marked secure but sent over plain HTTP is silently discarded by the browser. You can be wrong in either direction, and both failures are quiet.
So the scheme is supplied by Cookie::make(), which has a live request. Behind a
TLS-terminating proxy the scheme comes from X-Forwarded-Proto; if your proxy does not
send it, fix it through the defaults.
SetCookie::forget()
SetCookie::forget('sid');
SetCookie::forget('sid', '/admin', 'example.com');| Argument | Type | Default | Purpose |
|---|---|---|---|
$name |
string |
— | Cookie to remove |
$path |
string |
/ |
Path it was set with |
$domain |
?string |
null |
Domain it was set with |
Builds a cookie with an empty value and an expiry in the past:
sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=LaxPath and domain have to match
To a browser, path and domain are part of a cookie’s identity. A cookie set on
/admin is not removed by a deletion on /: the browser sees a different cookie, and the
original lives on.
This is the most common reason “logging out doesn’t work”.
Lifetime
expiresIn()
Cookie::make('sid', $token)->expiresIn(3600); // an hour
Cookie::make('remember', $t)->expiresIn(60 * 60 * 24 * 30); // a month| Argument | Type | Purpose |
|---|---|---|
$seconds |
int |
How long to live, counting from now |
Which goes out as:
sid=abc; Expires=Tue, 19 Aug 2025 11:40:00 GMT; Max-Age=3600; Path=/; HttpOnly; SameSite=LaxBoth attributes at once is not redundancy: Max-Age is what modern browsers obey,
Expires is what the oldest understand, and RFC 6265 gives Max-Age precedence where
both appear. The pair is safe rather than contradictory.
expiresAt()
Cookie::make('promo', 'x')->expiresAt(new DateTimeImmutable('2026-01-01'));
Cookie::make('promo', 'x')->expiresAt(1767225600);| Argument | Type | Purpose |
|---|---|---|
$moment |
DateTimeInterface|int |
An absolute moment, or a unix timestamp |
For “until the promotion ends” and “until midnight” — where the date matters, not the
duration. An expiry in the past means deletion, and no negative Max-Age is emitted for
it (some clients treat that as a parse error) — it becomes Max-Age=0.
session()
Cookie::make('csrf', $token)->session();Drops the lifetime entirely: the cookie lives until the browser closes. It is the default, and it is what you want from a CSRF token — no reason for it to outlive the window.
Scope
path()
Cookie::make('admin_pref', '1')->path('/admin');| Argument | Type | Default | Purpose |
|---|---|---|---|
$path |
string |
/ |
URL prefix the cookie is sent for |
The browser attaches it to /admin and /admin/users, but not to /. Narrowing the path
is not a security boundary — any page of the site can still call /admin — but a way to
stop carrying the value in every request.
domain()
Cookie::make('sid', $t)->domain('example.com'); // + api.example.com, www.example.com
Cookie::make('sid', $t)->domain(null); // this host only| Argument | Type | Default | Purpose |
|---|---|---|---|
$domain |
?string |
null |
Domain; null means this host only, no subdomains |
Without a domain the cookie is host-only, and that is the safer option: it will not travel to a neighbouring subdomain another team may own. Set a domain only when several subdomains genuinely need to see the cookie.
A leading dot (.example.com) is a leftover: modern browsers ignore it, and example.com
already covers subdomains.
Security flags
httpOnly()
Cookie::make('sid', $token)->httpOnly(); // on by default
Cookie::make('theme', 'dark')->httpOnly(false); // readable by scripts| Argument | Type | Default | Purpose |
|---|---|---|---|
$httpOnly |
bool |
true |
Hide the cookie from document.cookie |
On by default. Turn it off only for a value the page’s own JavaScript really reads — a
theme, a collapsed panel, a wizard step. A session token is never that value: with
HttpOnly, an XSS found on the page cannot carry it away.
secure()
Cookie::make('sid', $token)->secure(); // Cookie::make() already did this on https
SetCookie::make('sid', $token)->secure();| Argument | Type | Default | Purpose |
|---|---|---|---|
$secure |
bool |
true |
Send over HTTPS only |
sameSite()
use Flytachi\Winter\Kernel\Http\Cookie\SameSite;
Cookie::make('sid', $t)->sameSite(SameSite::Lax); // the default
Cookie::make('sid', $t)->sameSite(SameSite::Strict);
Cookie::make('w', $t)->secure()->sameSite(SameSite::None);
Cookie::make('a', $t)->sameSite(null); // omit the attribute| Value | When the browser attaches the cookie | What for |
|---|---|---|
Lax |
Same-site requests + link navigations (GET) | An ordinary site’s session |
Strict |
Same-site requests only | A bank, an admin panel; arriving by link reads as “logged out” |
None |
Always, cross-site included | A widget embedded in someone else’s page. Requires Secure |
null |
The attribute is not sent | Left to the browser (today: like Lax) |
This is the CSRF defence: without SameSite the browser attaches your cookie to a request
a foreign page initiated, and the server cannot tell it from a genuine one.
partitioned()
Cookie::make('widget', $t)->secure()->sameSite(SameSite::None)->partitioned();| Argument | Type | Default | Purpose |
|---|---|---|---|
$partitioned |
bool |
true |
A separate cookie jar per embedding site |
CHIPS: a widget embedded in a.com and in b.com gets two independent cookies and cannot
use them to link one user across the two sites. Requires Secure.
The value
raw()
Cookie::make('jwt', $jwt)->raw();| Argument | Type | Default | Purpose |
|---|---|---|---|
$raw |
bool |
true |
Send the value as-is, without URL encoding |
By default the value is encoded: a b/c goes out as a%20b%2Fc and comes back decoded.
Turn that off for values that are already safe — a JWT, a hex digest — so an intermediary
does not encode them a second time.
A raw value is checked: a space, a quote, a comma, a semicolon or a control character raises an exception. Unchecked, a semicolon would end the cookie early and the browser would read the rest as attributes.
value()
$template = SetCookie::make('sid')->secure()->sameSite(SameSite::Strict)->expiresIn(3600);
$forAlice = $template->value($aliceToken);
$forBob = $template->value($bobToken);| Argument | Type | Purpose |
|---|---|---|
$value |
string |
New value; attributes are kept |
This is what the immutability is for: a prototype can be handed around without the fear that someone changes it under everyone else.
Sending
Cookie::add()
Cookie::add(Cookie::make('sid', $token)->expiresIn(3600));| Argument | Type | Purpose |
|---|---|---|
$cookie |
SetCookie |
What to send |
The cookie reaches the response at once, exactly as a header does — nothing is accumulated until the end of processing. That matters more than it looks:
Cookie::forget('sid'); // the session is dead
throw new ResponseException('Unauthorized', HttpCode::UNAUTHORIZED);Had cookies been flushed on the success path, this one — the one that matters most — would have been lost precisely when the request failed, leaving the browser with a dead session.
Outside a request add() throws a LogicException: there is nothing to write to, and a
silently dropped cookie looks like “the browser ignored it”, which is far more expensive
to debug.
Cookie::forget()
Cookie::forget('sid');
Cookie::forget('sid', '/admin', 'example.com');Shorthand for Cookie::add(SetCookie::forget(...)). Same arguments, and the same
requirement that path and domain match.
ResponseEntity::cookie()
return ResponseEntity::ok(['ok' => true])
->cookie(SetCookie::make('sid', $token)->expiresIn(3600))
->cookie(SetCookie::make('theme', 'dark')->httpOnly(false));| Argument | Type | Purpose |
|---|---|---|
$cookie |
SetCookie |
What to send with this response |
The declarative door: the cookie is part of the returned response rather than a side effect. Call it as often as you like — cookies are kept in a list, not in a map keyed by name.
Both ways add up in one response, and the order is preserved.
The other response types
->cookie() is on all four responses a controller can return:
| Type | Example |
|---|---|
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) |
Choosing a response type is a decision about form — JSON, a page, a file. It must not also decide whether a session can be opened. A login that answers with a rendered page sets its cookie the same way a login answering with JSON does:
return ResponseView::view('dashboard', ['user' => $user])
->cookie(Cookie::make('sid', $token)->expiresIn(3600));Application defaults
Cookie::defaults()
use Flytachi\Winter\Kernel\Http\Cookie\{Cookie, SameSite, SetCookie};
Cookie::defaults(fn(SetCookie $c) => $c
->domain('example.com')
->sameSite(SameSite::Strict));| Argument | Type | Purpose |
|---|---|---|
$configure |
?Closure(SetCookie): SetCookie |
Applied to every cookie from Cookie::make(); null clears it |
Set once at boot. It is a function, not a fixed prototype, and the difference is real:
it runs after the scheme-derived Secure, so an application can overrule even that:
// Behind a proxy that terminates TLS and does not forward X-Forwarded-Proto
Cookie::defaults(fn(SetCookie $c) => $c->secure());Defaults do not touch SetCookie::make() — that version stays pure.
What will not build
The object refuses to produce a cookie that cannot work, before the browser silently discards it:
| Situation | What happens |
|---|---|
SameSite=None without Secure |
InvalidArgumentException — the browser would discard it |
Partitioned without Secure |
InvalidArgumentException |
raw() with a space, ;, , or a quote in the value |
InvalidArgumentException |
A name with a space, =, ;, ,, parentheses or a slash |
InvalidArgumentException at build time |
| An empty name | InvalidArgumentException |
Cookie 'sid': SameSite=None requires Secure, or the browser discards the cookie.The order of the calls does not matter: ->sameSite(None)->secure() and
->secure()->sameSite(None) are equally fine — the check runs when the header is
assembled, not in every setter.
Why not $_COOKIE, and not $res->cookie()
The layer uses neither PHP’s parsing nor Swoole’s, and there are measured reasons.
On the way in, PHP renames the names. One and the same request:
Cookie: my.sid=1; my sid=2; ok=3
$_COOKIE → ["my_sid", "ok"] the dot renamed, the second cookie gone
Winter → ["my.sid", "my sid", "ok"]Swoole parses the header itself and does not mangle it that way — so on $_COOKIE the two
modes would report different key sets. Winter parses the raw Cookie header in both, so
the names match. Everything else deliberately mirrors $_COOKIE: of two same-named
cookies the first wins, an empty value is kept, and a name with no = reads as an empty
string.
On the way out, Swoole’s native Swoole\Http\Response::cookie() spells the attributes
its own way and encodes a space as +:
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 builds the string itself and hands it to both runtimes verbatim — Swoole and FPM send the same bytes, and the tests asserting those bytes mean something.
What is not here
Neither signing nor encryption of the value. That is deliberate: the kernel provides the
mechanism, the developer chooses the policy. If you want a signature, compute an HMAC over
the value before make() and verify it after get(); if you want an opaque identifier,
keep the data on your side and put only the key in the cookie.
Sessions are a layer above cookies, not part of this page.
Next
- Responses —
ResponseEntity, status codes and headers - Requests — what else arrives alongside cookies
- Middleware — a convenient place to open and close a session