Web basics

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.

Read Cookie::get()Write Cookie::add()Value SetCookie

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

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';
}

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

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

php
Cookie::has('consent');  // true — even when the value is empty

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

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

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

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

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

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

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

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

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

text
sid=abc; Expires=Tue, 19 Aug 2025 11:40:00 GMT; Max-Age=3600; Path=/; HttpOnly; SameSite=Lax

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Application defaults

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));
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:

php
// 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
text
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.

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:

text
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 +:

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

  • ResponsesResponseEntity, status codes and headers
  • Requests — what else arrives alongside cookies
  • Middleware — a convenient place to open and close a session