The Widget That Forgets: Partitioned Cookies in PHP 8.5 and Laravel 13

PHP 8.5 adds a partitioned option to setcookie() for CHIPS cookies. Emit one from plain PHP and from Laravel 13, scope it to iframe routes, and verify it.

Steven Richardson
Steven Richardson
· 10 min read

You ship an embeddable widget — a booking form, a support chat, a hosted checkout. It works perfectly on your own domain and loses its session on every navigation once a customer embeds it on theirs. You add SameSite=None; Secure, it half works, and somewhere in a spec document you find an attribute called Partitioned. Until PHP 8.5, emitting it meant abandoning setcookie() and writing the Set-Cookie header by hand.

Before changing any code, prove the cookie is being rejected rather than never set. Open the parent page that embeds your widget, then DevTools → Application → Cookies, and select your widget's origin in the left-hand tree. If the cookie is present when you load the widget directly but absent in the embedded context, the browser is dropping it on storage, not on transmission. The Issues panel will say so explicitly, usually with a "cookie was blocked because it is a third-party cookie" entry that names the Set-Cookie header.

That distinction matters. A cookie that is never emitted is an application bug. A cookie that is emitted and dropped is a partitioning problem, and partitioning is the only fix that browsers are still committed to supporting.

Understand what the partition key changes#

A normal cookie is stored under a single key: the host that set it. Partitioning adds a second key — the site of the top-level page, including the scheme. Your widget on https://site-a.example and the same widget on https://site-b.example write into two separate jars and neither can read the other. Subdomains of the parent still share a jar, so shoppy.example, support.shoppy.example and checkout.shoppy.example all see the same cookie, which is what makes the pattern usable for a real support widget.

That is the security point, and it is also the thing that quietly breaks features. Anything that relied on recognising the same user across two unrelated embedding sites — "you're already signed in to our widget over on that other site" — stops working permanently. There is no flag that brings it back.

CHIPS reached Baseline newly available in December 2025, so Chromium, Firefox and Safari all honour the attribute now.

PHP 8.5 adds partitioned to the options-array form of setcookie() and setrawcookie(). Only the array overload is affected — the positional signature is unchanged, exactly as it was when samesite landed in 7.3. Pass it alongside secure, and PHP writes the attribute for you.

<?php

setcookie('widget_state', $payload, [
    'expires' => time() + 60 * 60 * 24 * 7,
    'path' => '/',            // the CHIPS spec expects a root path
    'secure' => true,         // mandatory, see below
    'httponly' => true,
    'samesite' => 'None',     // without this it is never sent cross-site anyway
    'partitioned' => true,
]);

That produces:

Set-Cookie: widget_state=abc123; expires=Thu, 24 Sep 2026 07:00:00 GMT; Max-Age=604800; path=/; secure; HttpOnly; SameSite=None; Partitioned

The old workaround is worth seeing once, because it explains why so much embedded-widget code looks the way it does:

<?php

// Pre-8.5: setcookie() rejects the key, so you build the header yourself.
header(sprintf(
    'Set-Cookie: widget_state=%s; Expires=%s; Path=/; Secure; HttpOnly; SameSite=None; Partitioned',
    rawurlencode($payload),
    gmdate('D, d M Y H:i:s \G\M\T', time() + 604800),
), replace: false); // replace: true would wipe every other Set-Cookie header

You re-implement URL encoding, you re-implement the RFC date format, and you have to remember replace: false or the first cookie in the response eats all the others. PHP 8.5 removes the whole category.

partitioned and secure are not independently optional. Omit secure and PHP throws before a single byte reaches the client:

Uncaught ValueError: setcookie(): "partitioned" option cannot be used without "secure" option

SameSite=None is not enforced by PHP, but without it the cookie is never sent in a cross-site request, so partitioning it achieves nothing.

Set the flag through Laravel, not through setcookie()#

Laravel 13 routes every cookie through Symfony's Cookie object, and Illuminate\Cookie\CookieJar::make() still has no $partitioned parameter — it stops at $sameSite. The flag lives one layer down, on withPartitioned(), so build the cookie through the framework and flip it on the object you get back.

use Illuminate\Support\Facades\Cookie;

$cookie = Cookie::make(
    'widget_state',
    $payload,
    60 * 24 * 7, // minutes
    '/',         // path
    null,        // domain — leave null so the cookie stays host-only
    true,        // secure
    true,        // httpOnly
    false,       // raw
    'none',      // sameSite
)->withPartitioned();

Cookie::queue($cookie);

CookieJar::queue() accepts a ready-made Symfony Cookie instance, so the queue and the response pipeline behave exactly as they would for any other cookie. That matters more than it looks: EncryptCookies re-emits each cookie with $cookie->withValue($encrypted), and because withValue() clones, your Partitioned flag survives encryption untouched.

Dropping to a raw setcookie() call inside a Laravel controller skips both the queue and EncryptCookies. The value goes out in plain text, EncryptCookies then fails to decrypt it on the next request, and you get a DecryptException that looks convincingly like an APP_KEY problem. It is not. It is a cookie that was never encrypted in the first place.

Partition the Laravel session and CSRF cookies#

The session cookie needs the same treatment, and here Laravel does expose a config knob. config/session.php carries a partitioned key that StartSession::addCookieToResponse() passes straight through as the tenth argument to Symfony's Cookie constructor.

// config/session.php
'secure' => env('SESSION_SECURE_COOKIE', true),
'same_site' => env('SESSION_SAME_SITE', 'none'),
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
# .env for the embedded deployment only
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=none
SESSION_PARTITIONED_COOKIE=true

The same config array feeds PreventRequestForgery::newCookie(), so the XSRF-TOKEN cookie is partitioned by the same three settings — no separate switch. If you have not met Laravel 13's replacement for VerifyCsrfToken yet, the origin-aware CSRF middleware is worth reading before you loosen same_site to none, because the two protections interact.

Keep these three values in the environment rather than hardcoded, because the embedded deployment wants them and your first-party deployment does not. If you are managing that split across environments, the same discipline described in Laravel secrets in production applies here. And if your widget authenticates a SPA rather than serving Blade, read Sanctum's SPA session model first — stateful SPA auth and cross-site iframes have overlapping assumptions that are easy to break at the same time.

Scope partitioning to the embedded routes#

Most applications want partitioned cookies on the iframe routes only. Ordering decides whether that works: on the response path, middleware unwinds outermost-last, so a middleware that rewrites cookie flags has to sit outside EncryptCookies to see the finished set.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class PartitionEmbeddedCookies
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        foreach ($response->headers->getCookies() as $cookie) {
            // with*() returns a clone, so the encrypted value is preserved.
            $response->headers->setCookie(
                $cookie->withSecure()->withSameSite('none')->withPartitioned()
            );
        }

        // An iframe that cannot render is the other half of this problem.
        $response->headers->remove('X-Frame-Options');
        $response->headers->set(
            'Content-Security-Policy',
            'frame-ancestors https://shoppy.example https://*.shoppy.example',
        );

        return $response;
    }
}

Register it as the first entry of a dedicated group rather than bolting it onto web:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->group('embed', [
        \App\Http\Middleware\PartitionEmbeddedCookies::class, // outermost: runs last on the way out
        \Illuminate\Cookie\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ]);
})
// routes/web.php
Route::middleware('embed')->prefix('embed')->group(function (): void {
    Route::get('/chat', ChatWidgetController::class);
});

Attaching the middleware at route level instead would put it inside the web group, where it runs before StartSession has added the session cookie — and it would silently miss the one cookie you cared most about.

Verify the partition key in DevTools#

Load the widget on a real parent domain over HTTPS, open DevTools → Application → Cookies, and turn on the Partition Key Site column from the column header's context menu. A correctly partitioned cookie shows your widget's host in the Name/Domain columns and the parent site in Partition Key Site. If that column is empty, the attribute did not survive the round trip.

Then do the two-domain test, which is the only one that proves anything: embed the widget on two unrelated parents and confirm two rows for the same cookie name with different partition keys, and that state written on one does not appear on the other.

Lock it in with a test so nobody removes the flag during a refactor. There is no assertCookiePartitioned helper, but TestResponse::getCookie() hands back the Symfony object with the flag intact:

it('marks the widget cookie as partitioned', function (): void {
    $cookie = $this->get('/embed/chat')->getCookie('widget_state', decrypt: false);

    expect($cookie->isPartitioned())->toBeTrue()
        ->and($cookie->isSecure())->toBeTrue()
        ->and($cookie->getSameSite())->toBe('none');
});

For the parts a feature test cannot reach — whether the browser actually stored it — drive a real browser. Pest 4 browser testing with Playwright will load the iframe and let you assert on storage rather than on headers.

Gotchas and Edge Cases#

localhost lies to you. Browsers special-case Secure cookies on http://localhost, and they do it inconsistently. Partitioned cookies routinely "don't work" in development for reasons that have nothing to do with your code. Use real HTTPS locally — Herd, mkcert or Caddy all get you there in a few minutes.

PHP below 8.5 throws, it does not ignore. Since PHP 8.0, an unrecognised key in the setcookie() options array raises a ValueError rather than a warning. On 8.4, 'partitioned' => true is a fatal error, not a no-op. If your codebase still supports both, guard with PHP_VERSION_ID >= 80500 and fall back to the header() form. Worth checking the rest of your upgrade surface at the same time — the PHP 8.5 deprecations cheat sheet covers what else moves.

Capitalisation differs by layer. PHP core emits ; Partitioned; Symfony's Cookie::__toString() emits ; partitioned. Cookie attribute names are case-insensitive, so both are correct — but do not write a test that string-matches the capitalised form against a Laravel response, because it will fail for the wrong reason.

The __Host- prefix is recommended and fussy. MDN suggests __Host- for partitioned cookies, which binds them to the exact host. It also requires Secure, Path=/ and no Domain attribute. If SESSION_DOMAIN is set, or you pass a domain to Cookie::make(), the browser rejects the cookie outright and gives you very little to debug with.

Check the header at the edge, not the origin. Some CDNs and WAFs have historically stripped cookie attributes they did not recognise. curl -sSI https://widget.example/embed/chat against the public hostname, not against the origin behind it.

Partitioning a first-party-only cookie is pointless. It costs nothing today and fragments state the day that domain is embedded somewhere. Apply it where it belongs and nowhere else.

Wrapping Up#

The one-question test: is my application ever loaded in an iframe on a domain I do not control? If the answer is no, close this tab — a dashboard, a shop, a SPA on the same site all gain nothing here and inherit a subtle class of session bug. If the answer is yes, set partitioned at the point you create the cookie, move the session cookie onto the config knob, and design out anything that assumed one user could be recognised across two embedding sites, because that is gone for good.

Next: PHP 8.5's URI extension is the other 8.5 addition that quietly deletes a pile of hand-rolled code, and Laravel 13's PreventRequestForgery is the middleware you will be reasoning about the moment same_site becomes none.

FAQ#

What is a partitioned cookie in PHP 8.5?

A partitioned cookie carries the Partitioned attribute, which tells the browser to store it under two keys instead of one: the host that set it, and the site of the top-level page. PHP 8.5 added a partitioned entry to the options array accepted by setcookie() and setrawcookie(), plus a cookie_partitioned option for session_start() and session_set_cookie_params(). Before 8.5 the only way to emit the attribute from PHP was to build the Set-Cookie header by hand.

What are CHIPS cookies and when do I need them?

CHIPS stands for Cookies Having Independent Partitioned State. You need it when your own application is loaded inside an iframe on a domain you do not control — an embedded checkout, a support chat widget, an SSO helper frame — and needs to keep state across navigations on that parent site. A first-party application that is never embedded gains nothing from it.

Do partitioned cookies require Secure and SameSite=None?

Secure is mandatory. PHP 8.5 throws a ValueError at the setcookie() call if you pass partitioned without secure, and browsers reject the combination too. SameSite=None is not enforced by PHP, but without it the cookie is never sent on a cross-site request in the first place, so partitioning it has no effect. Treat Secure, SameSite=None, Path=/ and Partitioned as one indivisible combination.

How do I set a partitioned cookie in Laravel?

Build the cookie with Cookie::make() as usual, then call ->withPartitioned() on the Symfony Cookie object it returns and pass that to Cookie::queue(). Laravel 13's CookieJar::make() has no partitioned argument of its own. For the session and XSRF-TOKEN cookies, set partitioned to true in config/session.php alongside secure and same_siteStartSession and PreventRequestForgery both read that key.

Will partitioned cookies fix my iframe login breaking in Chrome?

Often, but only if the cookie was being blocked as a third-party cookie rather than being blocked for another reason. Check the DevTools Issues panel first. Partitioning also will not restore a login that depended on recognising the user from a different parent site — that is exactly the behaviour it removes, and you will need an explicit handshake, a token in the embed URL, or an account link instead.

Do I need partitioned cookies for a normal first-party website?

No. If your application is never rendered in an iframe on a third-party domain, partitioning changes nothing for the better and introduces a real risk: the day you flip same_site to none to make partitioning meaningful, you have widened your CSRF surface for no benefit. Leave session.partitioned at false and move on.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.