PHP 8.6 Changes Your Session Defaults: What Breaks in a Laravel App

PHP 8.6 session security defaults flip strict mode, HttpOnly and SameSite=Lax. Here's what actually breaks in a Laravel app, and the fix for each case.

Steven Richardson
Steven Richardson
· 10 min read

The failure does not show up in CI. It shows up as a login loop on production, on the one endpoint that nobody owns, three days after the PHP upgrade went out.

PHP 8.6 ships three changed session ini defaults, and the upgrade notes produce either panic or a shrug. Both are wrong. Here is what actually happens to a Laravel app, and the fix for each case that genuinely breaks.

Know the three PHP 8.6 session security defaults that change#

The RFC changes three default values in ext/session/session.c. No new ini entries, no new functions. These are defaults only — an explicit value in php.ini, or an ini_set() call, still wins.

INI setting Old default New default
session.use_strict_mode 0 1
session.cookie_httponly 0 1
session.cookie_samesite not set Lax

All three votes passed unanimously on the PHP internals list, so this is settled for the 19 November 2026 GA. If you are working through the wider upgrade, I covered the ordering in upgrading a Laravel app to PHP 8.6 — this article is the session chapter of that checklist in full.

Decide whether the PHP 8.6 session security defaults reach your Laravel app#

For most Laravel apps, two of the three changes are a non-event. Laravel does not write the session cookie through the session extension. It writes it through Symfony's cookie layer, using values from config/session.php:

// config/session.php — the keys that already govern cookie flags

'secure' => env('SESSION_SECURE_COOKIE'),      // overrides session.cookie_secure

'http_only' => env('SESSION_HTTP_ONLY', true), // overrides session.cookie_httponly

'same_site' => env('SESSION_SAME_SITE', 'lax'),// overrides session.cookie_samesite

A default Laravel install has been http_only => true and same_site => 'lax' for years. PHP 8.6 is catching up to the framework, not changing it.

Three places where that insulation stops:

  • Legacy code calling session_start() directly. A wp-adjacent bridge, an old payment callback, a vendored library that predates the framework boundary. These use the real session extension and get the new defaults.
  • Packages that touch $_SESSION. Same problem, less visible.
  • Octane and FrankenPHP workers. Config is resolved once at boot. A .env change to SESSION_SAME_SITE does nothing until the workers restart, which is the same class of trap I hit in Laravel maintenance mode.

use_strict_mode is the one that is never about cookie flags, and it applies to anything using native sessions regardless of framework.

Fix the subdomain hand-off that strict mode rejects#

Session fixation is an attacker pre-planting a known session ID and waiting for a victim to authenticate against it. With use_strict_mode=0, session_start() accepts any well-formed ID from a cookie, $_GET or $_POST and opens a session under it without checking storage.

With strict mode on, PHP validates the proposed ID first. The files handler checks whether a session file exists; if not, it discards the ID and generates a fresh one.

That breaks the deliberate hand-off pattern — one app passing an ID to another across subdomains:

// app.example.com — broken under PHP 8.6
session_start();
$_SESSION['user_id'] = $user->id;

// The receiving side will reject this ID: nothing is on disk yet,
// because this request has not finished writing its session.
return redirect("https://reports.example.com/sso?sid=" . session_id());

The fix is to force the write before handing the ID over:

// app.example.com — correct under PHP 8.6
session_start();
$_SESSION['user_id'] = $user->id;

$sid = session_id();

session_write_close(); // flush to storage so validateId() finds the record

return redirect("https://reports.example.com/sso?sid={$sid}");

If you can replace the hand-off entirely, do. A signed, short-lived token is a better boundary than a shared session ID, and Laravel Sanctum already gives you the primitives.

One caveat worth being honest about: the RFC notes that a handler whose validateId() returns true unconditionally is unaffected. That includes the stock phpredis and php-memcached session handlers, which do not implement it at all. "Unaffected" there does not mean "safe" — it means strict mode cannot do its job on that backend.

HttpOnly is enforced by the browser. It excludes the cookie from document.cookie; it has no effect on whether the cookie is sent with requests. So nothing about your request flow changes.

What breaks is JavaScript that reads the session ID to put it in a custom header. That pattern has to go — the session ID is a server-side credential and client code should never see it. Use a separate, explicitly non-HttpOnly CSRF token instead, which is what Laravel's XSRF-TOKEN cookie already is. If you are hardening that layer anyway, Laravel 13's origin-aware CSRF protection is the better place to spend the effort.

Restore cross-site POST for SAML and legacy endpoints#

Under SameSite=Lax, the browser sends the cookie on same-site requests and on top-level cross-site navigations using GET or HEAD. It does not send it on cross-site sub-resource requests, and it does not send it on cross-site POST.

Two integrations break on that last one: SP-initiated SAML SSO, where the IdP POSTs the assertion back to your ACS endpoint, and legacy cross-origin form submissions.

Scope the relaxation to those endpoints only, never globally:

namespace App\Http\Middleware;

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

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

        foreach ($response->headers->getCookies() as $cookie) {
            if ($cookie->getName() !== config('session.cookie')) {
                continue;
            }

            // setCookie() replaces by name/path/domain, so this swaps in place.
            $response->headers->setCookie(
                $cookie->withSameSite(Cookie::SAMESITE_NONE)->withSecure(true)
            );
        }

        return $response;
    }
}

Apply it to the ACS route group and nothing else. Chrome and Firefox have applied Lax implicitly for years, so if your SAML flow works today you have probably already solved this — Safari is the browser that never applied the implicit default, which is exactly why making it explicit in PHP closes the gap.

Add create_sid() and validateId() to your session handlers#

This is the part that is barely covered anywhere, and it is the one that fails loudly.

PHP 8.6 deprecates passing a handler object to session_set_save_handler() that lacks create_sid() or validateId(). Both become required members of SessionHandlerInterface in PHP 9.0. That much is an E_DEPRECATED.

The second check is the one to care about. PHP also inspects every class implementing SessionHandlerInterface at load time, and raises an E_WARNING:

Class ExampleSessionHandler implementing SessionHandlerInterface is missing
the create_sid() method which will be required in PHP 9.0

That check does not depend on session_set_save_handler(). Loading the class is enough. And because it is a warning rather than a deprecation, suppressing E_DEPRECATED will not hide it.

Laravel 13's own handlers — file, database, cookie, cache, array and null — implement SessionHandlerInterface without either method. I checked against the framework source: FileSessionHandler is a bare implements SessionHandlerInterface, and DatabaseSessionHandler adds only ExistenceAwareInterface. Each loaded handler can raise two warnings. Laravel does not register them through session_set_save_handler(), so you avoid the deprecation messages, not the warnings.

For your own handlers, implement all three interfaces:

use SessionHandlerInterface;
use SessionIdInterface;
use SessionUpdateTimestampHandlerInterface;

final class RedisSessionHandler implements
    SessionHandlerInterface,
    SessionIdInterface,
    SessionUpdateTimestampHandlerInterface
{
    public function __construct(private \Redis $redis, private int $ttl) {}

    public function create_sid(): string
    {
        // Never call session_create_id() here. While a session is active it
        // delegates back to this method, and you recurse until PHP dies.
        return bin2hex(random_bytes(16));
    }

    public function validateId(string $id): bool
    {
        // Strict mode is only as strong as this check.
        return (bool) $this->redis->exists($this->key($id));
    }

    public function updateTimestamp(string $id, string $data): bool
    {
        return $this->redis->expire($this->key($id), $this->ttl);
    }

    public function read(string $id): string
    {
        return (string) $this->redis->get($this->key($id));
    }

    public function write(string $id, string $data): bool
    {
        return $this->redis->setex($this->key($id), $this->ttl, $data);
    }

    public function destroy(string $id): bool
    {
        $this->redis->del($this->key($id));

        return true;
    }

    public function open(string $path, string $name): bool
    {
        return true;
    }

    public function close(): bool
    {
        return true;
    }

    public function gc(int $max_lifetime): int|false
    {
        return 0; // Redis TTLs handle expiry.
    }

    private function key(string $id): string
    {
        return "session:{$id}";
    }
}

The built-in SessionHandler class gains a working validateId() in 8.6 and already has create_sid(), so extending it is a legitimate shortcut — but only if its storage semantics match yours.

Run the pre-upgrade audit#

Three commands tell you where you stand. Run them against the target image, not your laptop.

# 1. Anything using native PHP sessions rather than the framework
rg -n 'session_start\(|\$_SESSION|session_set_save_handler\(|session_id\(' \
   app/ routes/ packages/ vendor/ --type php

# 2. Handler classes that will warn on load under 8.6
rg -n 'implements.*SessionHandlerInterface' app/ packages/ vendor/ --type php

# 3. What the deployed image actually sets — defaults only apply if nothing overrides
php -i | grep '^session\.'

That third command matters more than it looks. Most Docker base images ship a php.ini with these values set explicitly, which means the upgrade changes nothing for you — and also means the secure-by-default win never arrives. Check, do not assume. If you are building your own image, multi-stage Laravel Docker builds is where that php.ini should be pinned.

Assert the flags rather than trusting the config file, because config:cache and Octane both decouple what is in the repo from what is in the response:

it('sets http-only, lax flags on the session cookie', function () {
    $response = $this->get('/login');

    $cookie = collect($response->headers->getCookies())
        ->firstWhere(fn ($c) => $c->getName() === config('session.cookie'));

    expect($cookie)->not->toBeNull()
        ->and($cookie->isHttpOnly())->toBeTrue()
        ->and($cookie->getSameSite())->toBe('lax');
});

Add an inverse test for the SAML ACS route asserting none and secure, so nobody widens it by accident later.

Gotchas and Edge Cases#

SameSite=None without Secure is rejected outright. Browsers drop the cookie. Both attributes or neither — which is why the middleware above sets them together.

config:cache freezes .env. Changing SESSION_SAME_SITE does nothing until the config cache is rebuilt and, under Octane, until the workers restart.

Session storage backend does not change any of this. Switching drivers — including a Redis to Valkey migration — leaves the cookie flags alone, because those come from config/session.php.

The E_WARNING is not suppressible by lowering error reporting in the usual places. If your log aggregator is noisy after the upgrade, the framework handlers are the likely cause, and the fix is a framework patch release rather than anything in your app.

Strict mode plus a handler that cannot validate is theatre. If you are on the stock phpredis session handler, turning strict mode on changes nothing about your fixation exposure.

Wrapping Up#

Put PHP 8.6 in CI, run the three audit commands, and fix the native-session call sites before GA on 19 November. The cookie-flag changes are almost certainly a non-event for your Laravel app; the handler warnings and any SP-initiated SAML flow are the two things worth an afternoon.

From here, the sensible next steps are the full PHP 8.6 upgrade checklist for ordering the rest of the work, and the PHP 8.5 deprecations cheat sheet if you are jumping two versions and have not cleared that backlog yet.

FAQ#

What session defaults changed in PHP 8.6?

Three ini defaults changed. session.use_strict_mode goes from 0 to 1, session.cookie_httponly goes from 0 to 1, and session.cookie_samesite goes from unset to Lax. These are default values only, so any explicit setting in php.ini or an ini_set() call still takes precedence.

Does the PHP 8.6 session change affect Laravel?

Mostly not. Laravel writes its session cookie through Symfony's cookie layer using config/session.php, where http_only already defaults to true and same_site to lax, so the two cookie changes are a non-event for framework sessions. The exceptions are code that calls session_start() or touches $_SESSION directly, and the separate PHP 8.6 warning about Laravel's own session handler classes missing create_sid() and validateId().

What does session.use_strict_mode actually do?

It makes PHP validate a client-supplied session ID against storage before accepting it. Without it, session_start() will open a session under any well-formed ID from a cookie or query string, which is what makes session fixation possible. With it on, an unrecognised ID is discarded and a fresh one is generated instead.

Why did my SAML SSO login break after upgrading to PHP 8.6?

SP-initiated SAML has the identity provider POST the assertion back to your ACS endpoint, and that is a cross-site POST. Under SameSite=Lax the browser does not attach the session cookie to it, so the request arrives without the session that started the flow. The fix is to set SameSite=None; Secure on the session cookie for that endpoint only, or to move the hand-off to a signed token.

Can I still read the session cookie from JavaScript?

No, once HttpOnly is set the browser excludes the cookie from document.cookie. The cookie is still sent with every request as normal, so only client-side reads are affected. If JavaScript needs a value to echo back in a header, use a separate non-HttpOnly CSRF token rather than the session ID.

Do I need to change config/session.php for PHP 8.6?

No. A default Laravel install already sets http_only to true and same_site to lax, which matches or exceeds the new PHP defaults. The useful work is verifying that the deployed environment actually reflects that file, since config:cache and Octane workers can both serve a stale copy.

What is deprecated about session_set_save_handler in PHP 8.6?

Passing a handler object that does not define create_sid() and validateId() is deprecated, and both methods become required on SessionHandlerInterface in PHP 9.0. Separately, PHP raises an E_WARNING for any class implementing that interface without the methods, triggered simply by loading the class rather than by registering the handler.

Steven Richardson
Steven Richardson

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