Cookies
A cookie is a small named value the server asks the browser to keep and send back on every later visit to the site. It is the only way HTTP — a protocol that remembers nothing between requests — learns that two visits came from one and the same person.
What a cookie is and why it is needed
The problem. HTTP holds no state. A user types in an email and a password, the server recognises them — and the next request from that same browser arrives as if from a complete stranger. All the server has between two requests is whatever it asked the browser to remember.
The solution. The server sends a cookie in a Set-Cookie header, the browser stores
it and attaches it in a Cookie header to every later request to that site. Sessions,
“remember me”, the chosen theme and language, a cart before sign-up, protection against
forged requests — all of it is built on this.
The exchange itself is simple. The difficulty is in the attributes the cookie carries.
A cookie without HttpOnly is available to any script on the page. A cookie without
SameSite travels to another site along with a request some third party forged. A cookie
without Secure 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, and you find out later and from somewhere else.
So cookies in the framework are described by an object that refuses to be built incorrectly, rather than by a header string you must remember to compose correctly.
Quick start
use Flytachi\Winter\Kernel\Http\Cookie\Cookie;
#[PostMapping('login')]
public function login(#[RequestBody] LoginForm $form): ResponseEntity
{
$token = $this->auth->authenticate($form->email, $form->password);
Cookie::add(Cookie::make('sid', $token)->expiresIn(3600));
return ResponseEntity::noContent();
}
#[GetMapping('me')]
public function me(): ResponseEntity
{
$token = Cookie::get('sid');
if ($token === null) {
return ResponseEntity::unauthorized(['message' => 'Not signed in']);
}
return ResponseEntity::ok($this->auth->userByToken($token));
}
#[PostMapping('logout')]
public function logout(): ResponseEntity
{
Cookie::forget('sid');
return ResponseEntity::noContent();
}Nothing needs initialising: the router prepares cookie handling at the start of every request, before control reaches the controller.
One header, many values
Set-Cookie is the one HTTP header that may legitimately repeat: three cookies are sent
as three headers. That is why cookies have a path of their own into the response rather
than ->header('Set-Cookie', …) — the header map is keyed by name, and a second cookie
would overwrite the first.
Reference
Cookie
Cookie is the facade for working with the cookies of the current request. Through it you
read what the browser sent and send what should reach it.
Every method is static, yet the state behind the facade is per request, not per process: under Swoole it lives in the coroutine serving the request, and two concurrent requests never see each other’s cookies. In a one-request-per-process mode the state sits in a static field — there the isolation comes from the process itself.
Cookies you send are written into the response at once, exactly as headers are, rather than accumulating until the end of processing. The difference shows on an error:
Cookie::forget('sid'); // the session is dead — the cookie is already in the response
throw new ResponseException('Session expired', HttpCode::UNAUTHORIZED);Had cookies been sent at the end of the successful path, this one — the one that matters most — would have been lost precisely when the request ended in an error, leaving the browser with a dead session.
Cookie::get()
Returns the value of a cookie the browser sent.
The value arrives already decoded: if it was encoded on the way out — and by default it was — the reverse happens here.
Signature
public static function get(string $name): ?stringParameters
$name — the cookie’s name. Case matters: this is the form the client sent it in.
Returns
The value as a string, or null when the request carried no cookie under that name.
Example
$theme = Cookie::get('theme') ?? 'light';Cookie::has()
Reports whether the browser sent a cookie under the given name.
It differs from get() !== null in one case, and not a contrived one: consent= is a
cookie that was sent, with an empty value. get() returns an empty string while
has() returns true, and for consent flags or “already shown” markers the distinction
matters: an empty string means “answered”, absence means “never asked”.
Signature
public static function has(string $name): boolParameters
$name — the cookie’s name.
Returns
true when the cookie was sent, even with an empty value.
Example
if (!Cookie::has('cookie_consent')) {
return ResponseView::view('banners/cookie-consent');
}Cookie::all()
Returns every cookie of the request.
Signature
public static function all(): arrayReturns
A name-to-value array in the order the client sent them. An empty array when no cookies came, or when there is no request at all — in a console command, for instance.
Example
foreach (Cookie::all() as $name => $value) {
$this->logger->debug('Cookie in the request', ['name' => $name, 'length' => strlen($value)]);
}Cookie::make()
Creates a cookie description ready to be sent, taking the current request and the application’s settings into account.
It differs from SetCookie::make() in two ways. First, it sets Secure
when the request arrived over HTTPS: a value object cannot see the request scheme, and it
is possible to be wrong in either direction, both of them quietly — a cookie marked
Secure but sent over plain HTTP is silently discarded by the browser. Second, it applies
the application defaults, when any are set.
This is the variant application code wants.
Signature
public static function make(string $name, string $value = ''): SetCookieParameters
$name — the cookie’s name. It may hold letters, digits and some punctuation; a space, a
quote, a semicolon and brackets are not allowed. Breaking that raises an
InvalidArgumentException.
$value — the value, empty by default. It is encoded on the way out, so any text may be
put in.
Returns
A SetCookie object with the defaults applied. It is then configured
through chained calls and sent with Cookie::add().
Example
$cookie = Cookie::make('theme', 'dark')
->expiresIn(60 * 60 * 24 * 365)
->httpOnly(false); // read by a script on the pageCookie::add()
Sends a cookie to the client — writes it into the current request’s response.
The write happens immediately, not at the end of processing. So a cookie set before a thrown exception still reaches the browser.
Signature
public static function add(SetCookie $cookie): voidParameters
$cookie — a cookie description built with Cookie::make() or
SetCookie::make().
Errors
LogicException — when called outside a request, from a console command for example.
There is nothing to write to, and a silently dropped cookie looks to the caller like “the
browser ignored it”, which costs incomparably more to debug than a clear error.
InvalidArgumentException — when the cookie is built incorrectly; the cases are listed
under What will not build.
Example
Cookie::add(Cookie::make('sid', $token)->expiresIn(3600));Cookie::forget()
Asks the browser to delete a cookie.
HTTP has no “delete cookie” command: deleting means sending a cookie with an empty value and a lifetime in the past. The method does exactly that, sparing you from assembling such a cookie by hand.
Signature
public static function forget(string $name, string $path = '/', ?string $domain = null): voidParameters
$name — the cookie to remove.
$path — the path the cookie was set with. / by default.
$domain — the domain the cookie was set with. null by default, meaning a cookie
with no domain.
Example
Cookie::forget('sid');
Cookie::forget('admin_pref', '/admin');Path and domain must match the ones used when setting it
To a browser the path and the domain are part of the cookie’s identity, not an
addition to it. A cookie set on /admin is not removed by a deletion on /: the browser
sees a request to delete a different cookie, while the original lives on and keeps being
sent.
This is the most common cause of “signing out doesn’t work”.
Cookie::defaults()
Sets the attributes every cookie from Cookie::make() starts with.
Configured once at application start. It takes not a fixed prototype but a function,
and the difference is real: the function runs after Secure has been derived from the
request scheme, so the application can overrule even that — when TLS is terminated at a
proxy, for instance, and the request reaches the application over plain HTTP.
Signature
public static function defaults(?Closure $configure): voidParameters
$configure — a function taking a SetCookie and returning a SetCookie. null clears
any defaults previously set.
Example
use Flytachi\Winter\Kernel\Http\Cookie\{Cookie, SameSite, SetCookie};
protected static function configure(ApplicationArguments $args): void
{
Cookie::defaults(fn(SetCookie $cookie) => $cookie
->domain('example.com')
->sameSite(SameSite::Strict));
}Note
The defaults do not touch SetCookie::make() — that version stays pure
and knows nothing of the request or the settings.
Cookie::init() and Cookie::clear()
Lifecycle methods. Application code does not call them.
init() is called by the router at the start of every request: it parses the cookies that
arrived and remembers the response the outgoing ones will be written into. clear()
discards that state — needed between requests in one-request-per-process mode, and in
tests.
public static function init(HttpRequest $request, HttpResponse $response): void
public static function clear(): voidSetCookie
SetCookie is the description of a single cookie: its name, its value and every attribute
it will carry to the browser.
The object is immutable: every configuring method returns a new instance and leaves the original as it was. Thanks to that a prototype can be kept in one place and handed around without fear of someone spoiling it from another:
$base = SetCookie::make('sid')->secure()->httpOnly()->expiresIn(3600);
$forFirstUser = $base->value($firstToken); // $base is unchanged
$forSecondUser = $base->value($secondToken);The defaults are chosen to be safe:
| Attribute | Value | Why |
|---|---|---|
Path |
/ |
the cookie covers the whole site |
HttpOnly |
on | scripts on the page cannot read it |
SameSite |
Lax |
what modern browsers apply anyway |
| Lifetime | session | the cookie dies with the browser window |
Secure |
off | see below |
Secure is deliberately not among the 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. The scheme is supplied by Cookie::make(), which has a live
request.
SetCookie::make()
Creates a cookie description without regard to the request or the application’s settings.
Needed where there is no request: in tests, in a background job, when building a prototype
at start-up. In a controller Cookie::make() is the usual choice.
Signature
public static function make(string $name, string $value = ''): SetCookieParameters
$name — the cookie’s name.
$value — the value, empty by default.
Errors
InvalidArgumentException — when the name is empty or holds a character not allowed in a
cookie name: a space, =, ;, ,, a quote, a bracket, a slash, a control character.
Example
use Flytachi\Winter\Kernel\Http\Cookie\SetCookie;
$cookie = SetCookie::make('locale', 'en');SetCookie::forget()
Builds a cookie that deletes another cookie of the same name.
The same thing Cookie::forget() does, but it returns the object instead
of sending it straight away. Useful when the deletion has to be attached to a particular
response rather than to the current request.
Signature
public static function forget(string $name, string $path = '/', ?string $domain = null): SetCookieParameters
The same as Cookie::forget(): the name, plus the path and domain the
cookie was set with.
Returns
A cookie with an empty value and an expiry in the past.
Result
sid=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; HttpOnly; SameSite=LaxExample
return ResponseEntity::noContent()->cookie(SetCookie::forget('sid'));expiresIn()
Sets the cookie’s lifetime in seconds from now.
The commonest form: “an hour”, “a month”, “a year” are naturally expressed as a duration rather than a date.
Signature
public function expiresIn(int $seconds): SetCookieParameters
$seconds — how long the cookie should live. Zero or a negative value means deletion —
which is what SetCookie::forget() is built on.
Returns
A new object with that lifetime.
Example
Cookie::make('sid', $token)->expiresIn(3600); // an hour
Cookie::make('remember', $token)->expiresIn(60 * 60 * 24 * 30); // a monthResult
sid=abc; Expires=Tue, 19 Aug 2025 11:40:00 GMT; Max-Age=3600; Path=/; HttpOnly; SameSite=LaxBoth attributes go out together, and that is not redundancy: Max-Age is what modern
browsers obey, Expires is what the oldest understand, and where both appear the standard
says Max-Age wins. The pair is safe rather than contradictory.
expiresAt()
Sets the cookie’s lifetime as a specific moment.
Needed where a date matters rather than a duration: “until the sale ends”, “until midnight”, “until the subscription runs out”.
Signature
public function expiresAt(DateTimeInterface|int $moment): SetCookieParameters
$moment — a date object or a unix timestamp.
Returns
A new object with that expiry.
Example
Cookie::make('promo', 'summer-sale')
->expiresAt(new DateTimeImmutable('2026-09-01 00:00:00'));Note
A moment in the past means deleting the cookie. No negative Max-Age is sent for it —
Max-Age=0 goes instead, since some clients treat a negative value as a parse error.
session()
Removes the lifetime: the cookie lives until the browser closes.
This is the default behaviour, so the method is needed in one case only — when a lifetime was set earlier in the chain and has to be undone.
Signature
public function session(): SetCookieExample
$base = SetCookie::make('csrf')->expiresIn(3600);
// For this form the token must not outlive the browser window.
$oneTab = $base->value($token)->session();path()
Sets the URL prefix the browser will send the cookie for.
Signature
public function path(string $path): SetCookieParameters
$path — the path, / by default. A cookie with the path /admin is sent for /admin
and /admin/users, but not for /.
Returns
A new object with that path.
Example
Cookie::make('admin_sidebar', 'collapsed')->path('/admin');Note
Narrowing the path is not a security measure: any page of the site can still send a
request to /admin and the cookie will travel with it. It is a way of not carrying data
in every request.
domain()
Sets the domain the cookie belongs to.
Signature
public function domain(?string $domain): SetCookieParameters
$domain — the domain. null (the default) means “this host only, no subdomains” — and
that is the safer option: the cookie will not travel to a neighbouring subdomain another
team may own. A stated domain covers its subdomains too: example.com includes
api.example.com.
Returns
A new object with that domain.
Example
// The cookie has to work on example.com and on api.example.com alike
Cookie::make('sid', $token)->domain('example.com');Note
A leading dot (.example.com) is a leftover from an older standard. Modern browsers
ignore it; there is no need to write it.
httpOnly()
Forbids or allows access to the cookie from JavaScript.
On by default. With it the cookie is invisible to document.cookie, so a script-injection
vulnerability found on the page cannot steal it. For a session token that is a
requirement.
Signature
public function httpOnly(bool $httpOnly = true): SetCookieParameters
$httpOnly — false opens the cookie to scripts.
Returns
A new object.
Example
Cookie::make('sid', $token)->httpOnly(); // on anyway, but let it be explicit
Cookie::make('theme', 'dark')->httpOnly(false); // read by the theme scriptNote
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.
secure()
Requires the cookie to travel over HTTPS only.
Signature
public function secure(bool $secure = true): SetCookieParameters
$secure — false lifts the requirement.
Returns
A new object.
Example
SetCookie::make('sid', $token)->secure();Note
When built through Cookie::make() this attribute is already set if the
request arrived over HTTPS. Calling it by hand is needed in two cases: when the cookie is
built with SetCookie::make(), and when TLS is terminated at a proxy
and the request reaches the application over plain HTTP.
sameSite()
Decides whether the browser attaches the cookie to requests started by another site.
This is the main defence against cross-site request forgery. Without it the browser will send your cookie along with a request some foreign page composed, and the server cannot tell it from a genuine action by the user.
Signature
public function sameSite(?SameSite $sameSite): SetCookieParameters
$sameSite — a case of the SameSite enum, or null to omit the
attribute entirely and leave the decision to the browser.
Returns
A new object.
Values
| Value | When the browser attaches the cookie | What it suits |
|---|---|---|
SameSite::Lax |
same-site requests plus link navigations | an ordinary site’s session; the default |
SameSite::Strict |
same-site requests only | a bank, an admin panel. Arriving by an external link reads as “not signed in” |
SameSite::None |
always, cross-site included | a widget embedded in someone else’s page. Requires Secure |
null |
the attribute is not sent | the browser decides; today that is the same as Lax |
Example
use Flytachi\Winter\Kernel\Http\Cookie\SameSite;
Cookie::make('sid', $token)->sameSite(SameSite::Strict);partitioned()
Places the cookie in a separate store for each site the page is embedded in.
The mechanism is called CHIPS and exists for widgets. If your widget is embedded in both
a.example and b.example, without this attribute it gets one shared cookie and can use
it to link the user across the two sites. With it, each embedding site has its own.
Signature
public function partitioned(bool $partitioned = true): SetCookieParameters
$partitioned — false removes the attribute.
Returns
A new object.
Example
Cookie::make('widget_state', $state)
->secure()
->sameSite(SameSite::None)
->partitioned();Note
It requires Secure. A cookie with Partitioned but without it will not be built —
see the refusals.
raw()
Sends the value as-is, without encoding.
By default the value is encoded: a b/c goes out as a%20b%2Fc and comes back decoded.
Turning that off makes sense for values already made of safe characters — a JWT, a
hexadecimal signature, an identifier — so that an intermediary does not encode them a
second time.
Signature
public function raw(bool $raw = true): SetCookieParameters
$raw — false restores encoding.
Returns
A new object.
Errors
A raw value is checked when it is sent: a space, a quote, a comma, a semicolon or a
control character raises an InvalidArgumentException. Without that check a semicolon
would end the cookie early and everything after it would be read by the browser as
attributes.
Example
Cookie::make('token', $jwt)->raw();value()
Replaces the value while keeping every attribute.
The method exists for prototypes: configure a set of attributes once, then issue cookies with different values from it.
Signature
public function value(string $value): SetCookieParameters
$value — the new value.
Returns
A new object with the same set of attributes.
Example
$sessionCookie = SetCookie::make('sid')
->secure()
->httpOnly()
->sameSite(SameSite::Strict)
->expiresIn(3600);
Cookie::add($sessionCookie->value($token));Reading the attributes
Eleven methods returning what the object already holds. Application code rarely needs them — they are used in tests and where a cookie is checked before being sent.
public function getName(): string
public function getValue(): string
public function getPath(): string
public function getDomain(): ?string
public function getSameSite(): ?SameSite
public function getExpires(): ?int // a unix timestamp, if the lifetime was a moment
public function getMaxAge(): ?int // seconds, if the lifetime was a duration
public function isSecure(): bool
public function isHttpOnly(): bool
public function isPartitioned(): bool
public function isRaw(): boolExample
$cookie = Cookie::make('sid', $token)->expiresIn(3600);
if (!$cookie->isSecure() && $environment === 'production') {
throw new RuntimeException('In production a session cookie has to be Secure');
}toHeader()
Assembles the Set-Cookie value — what will go out in the response.
There is no need to call it by hand: the response does that when sending. The method is useful in tests that check the resulting bytes, and while debugging.
Signature
public function toHeader(?int $now = null): stringParameters
$now — the reference point for converting a duration into a date and back. The current
time by default; tests pass a fixed one so the result does not depend on when they ran.
Returns
The header value, without the header’s name.
Errors
InvalidArgumentException — when the cookie is built incorrectly. The check happens here
rather than in the setters: attributes are set one at a time, and the order
->sameSite(None)->secure() is as legitimate as the reverse.
Example
$header = SetCookie::make('sid', 'abc')
->expiresIn(3600)
->secure()
->toHeader(now: 1755600000);Result
sid=abc; Expires=Tue, 19 Aug 2025 11:40:00 GMT; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=LaxSameSite
The enum of SameSite attribute values. Three cases, covered above.
enum SameSite: string
{
case Lax = 'Lax';
case Strict = 'Strict';
case None = 'None';
}What will not build
The object refuses to produce a cookie the browser would silently discard anyway. The check happens while the header is assembled, that is, on sending.
| Situation | What happens |
|---|---|
SameSite::None without Secure |
InvalidArgumentException — the browser rejects such a cookie |
Partitioned without Secure |
InvalidArgumentException |
raw() with a space, ;, , or a quote in the value |
InvalidArgumentException |
A name with a space, =, ;, ,, brackets or a slash |
InvalidArgumentException on creation |
| An empty name | InvalidArgumentException on creation |
Cookie 'sid': SameSite=None requires Secure, or the browser discards the cookie.The point of the checks is that every one of those failures is silent: without them the cookie simply would not appear at the client, and the investigation would start in a browser rather than in the code.
Reading through the request object
Cookies are also available directly on the request, when it is already injected into the controller method. The pair of methods is named after the header pair: a cookie is read the way a header is.
public function getCookie(string $name): ?string
public function getCookies(): arrayExample
#[GetMapping('me')]
public function me(HttpRequest $request): ResponseEntity
{
$token = $request->getCookie('sid');
return ResponseEntity::ok(['authenticated' => $token !== null]);
}Why a map of strings rather than objects
In libraries of other languages this method sometimes hands back an array of cookie objects. That is deliberately not the case here: an incoming request carries only name-and-value pairs from the browser — no lifetime, no path, no domain.
Objects with empty fields would suggest that the attributes of a received cookie can be
read. A map of strings promises nothing the request does not contain. Attributes belong to
SetCookie, that is, to the outgoing side.
How it works inside
A section for the curious: none of this is needed in order to use cookies.
Reading goes through the raw header rather than PHP’s built-in parsing. PHP offers a ready-made array of cookies, but it rewrites the names. One and the same request:
Cookie: my.sid=1; my sid=2; ok=3
PHP's built-in parsing → ["my_sid", "ok"] the dot replaced, the second cookie gone
Winter → ["my.sid", "my sid", "ok"]Swoole parses the header itself and does not do that — so on the built-in parsing the framework’s two modes would report different sets of names, and an application working in one would break in the other. The header is therefore parsed by our own code, identically in both.
Everything else deliberately mirrors the familiar behaviour: of two cookies with one name
the first wins, an empty value is kept, and a name with no = reads as an empty string.
Writing assembles the header itself. The cookie-issuing facilities built into PHP and
into Swoole spell the attributes differently — different casing, different encoding of a
space. The string is assembled by toHeader(), so both modes send the same
bytes, and the tests asserting those bytes mean something.
Next
- Responses — the
cookie()method on any response type - Requests — what else arrives alongside cookies
- Middleware — a convenient place to open or close a session
- Localization — the language a user picked is kept in a cookie