Web basics

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.

Reading Cookie::get()Writing Cookie::add()Description SetCookie

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

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

php
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

php
public static function get(string $name): ?string

Parameters

$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

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

php
public static function has(string $name): bool

Parameters

$name — the cookie’s name.

Returns

true when the cookie was sent, even with an empty value.

Example

php
if (!Cookie::has('cookie_consent')) {
  return ResponseView::view('banners/cookie-consent');
}

Cookie::all()

Returns every cookie of the request.

Signature

php
public static function all(): array

Returns

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

php
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

php
public static function make(string $name, string $value = ''): SetCookie

Parameters

$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

php
$cookie = Cookie::make('theme', 'dark')
  ->expiresIn(60 * 60 * 24 * 365)
  ->httpOnly(false);   // read by a script on the page

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

php
public static function add(SetCookie $cookie): void

Parameters

$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

php
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

php
public static function forget(string $name, string $path = '/', ?string $domain = null): void

Parameters

$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

php
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

php
public static function defaults(?Closure $configure): void

Parameters

$configure — a function taking a SetCookie and returning a SetCookie. null clears any defaults previously set.

Example

main/Application.php
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.

php
public static function init(HttpRequest $request, HttpResponse $response): void
public static function clear(): void

SetCookie

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:

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

php
public static function make(string $name, string $value = ''): SetCookie

Parameters

$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

php
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

php
public static function forget(string $name, string $path = '/', ?string $domain = null): SetCookie

Parameters

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

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

Example

php
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

php
public function expiresIn(int $seconds): SetCookie

Parameters

$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

php
Cookie::make('sid', $token)->expiresIn(3600);                 // an hour
Cookie::make('remember', $token)->expiresIn(60 * 60 * 24 * 30);  // a month

Result

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

Both 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

php
public function expiresAt(DateTimeInterface|int $moment): SetCookie

Parameters

$moment — a date object or a unix timestamp.

Returns

A new object with that expiry.

Example

php
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

php
public function session(): SetCookie

Example

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

php
public function path(string $path): SetCookie

Parameters

$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

php
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

php
public function domain(?string $domain): SetCookie

Parameters

$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

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

php
public function httpOnly(bool $httpOnly = true): SetCookie

Parameters

$httpOnly — false opens the cookie to scripts.

Returns

A new object.

Example

php
Cookie::make('sid', $token)->httpOnly();            // on anyway, but let it be explicit
Cookie::make('theme', 'dark')->httpOnly(false);    // read by the theme script

Note

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

php
public function secure(bool $secure = true): SetCookie

Parameters

$secure — false lifts the requirement.

Returns

A new object.

Example

php
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

php
public function sameSite(?SameSite $sameSite): SetCookie

Parameters

$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

php
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

php
public function partitioned(bool $partitioned = true): SetCookie

Parameters

$partitioned — false removes the attribute.

Returns

A new object.

Example

php
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

php
public function raw(bool $raw = true): SetCookie

Parameters

$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

php
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

php
public function value(string $value): SetCookie

Parameters

$value — the new value.

Returns

A new object with the same set of attributes.

Example

php
$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.

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

Example

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

php
public function toHeader(?int $now = null): string

Parameters

$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

php
$header = SetCookie::make('sid', 'abc')
  ->expiresIn(3600)
  ->secure()
  ->toHeader(now: 1755600000);

Result

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

SameSite

The enum of SameSite attribute values. Three cases, covered above.

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

php
public function getCookie(string $name): ?string
public function getCookies(): array

Example

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

text
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