Sessions, Cookies and Flash Data

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Every example runs in the Bookshelf application used across this guide. Laravel does not use PHP’s native session extension: there is no session_start() and no $_SESSION. The web group’s StartSession middleware loads the session from a store, and saves it after the response. The native mechanism, and the attacks the framework protects against, are on Sessions and Cookies.

Configuration and drivers

config/session.php reads its settings from .env. A new application uses the database driver, with the sessions table created by the default 0001_01_01_000000_create_users_table.php migration:

# .env of a new application
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
Driver Use

database

The default since Laravel 11. Works on one or many servers sharing a database; also records user_id, ip_address, user_agent and last_activity per session, which "log out other devices" screens use.

redis

The usual choice under load: fast, and Redis expires keys itself. Needs phpredis or predis.

file

storage/framework/sessions, the default before Laravel 11. Single server only.

cookie

The whole session, encrypted, in the cookie itself. No server storage, but limited to about 4 KB and sent with every request; cannot be used with session blocking.

memcached, dynamodb, array

array keeps the session for one request only, which is what tests use.

The cookie that carries the session ID, and the CSRF cookie, as a new application sends them:

$ curl -s -D - -o /dev/null localhost:8000/s/native | grep -i set-cookie
Set-Cookie: XSRF-TOKEN=eyJ…; expires=Mon, 05 Oct 2026 22:10:15 GMT; Max-Age=7200; path=/; samesite=lax
Set-Cookie: laravel-session=eyJ…; expires=Mon, 05 Oct 2026 22:10:15 GMT; Max-Age=7200; path=/; httponly; samesite=lax
$ curl -s localhost:8000/s/native
{"session_status":"PHP_SESSION_NONE","superglobal":false,"cookie_name":"laravel-session"}

The session cookie is HttpOnly and SameSite=Lax; XSRF-TOKEN is deliberately readable by JavaScript so that Axios-style clients can echo it in an X-XSRF-TOKEN header. Neither has Secure locally: set SESSION_SECURE_COOKIE=true in production behind HTTPS. Max-Age=7200 is SESSION_LIFETIME (minutes) and is refreshed on every request, so 120 is an idle timeout; SESSION_EXPIRE_ON_CLOSE=true makes it a browser-session cookie instead. Both values are encrypted by EncryptCookies, and the session ID inside is a random 40-character string.

Reading and writing

The session is available as $request->session(), through the session() helper, or with the Session facade:

<?php
// app/Http/Controllers/SessionDemoController.php (excerpt)
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class SessionDemoController extends Controller
{
    public function visit(Request $request): array          // GET /s/visit
    {
        $session = $request->session();

        $session->increment('visits');                        // put('visits', get('visits', 0) + 1)
        $session->push('recent', $request->query('isbn', 'none'));
        $session->put('recent', array_slice($session->get('recent'), -3));

        return [
            'id' => substr($session->getId(), 0, 8).'…',
            'visits' => $session->get('visits'),
            'recent' => $session->get('recent'),
            'missing' => $session->get('theme', 'light'),      // default when absent
            'has_theme' => $session->has('theme'),
        ];
    }
}
$ curl -s -c jar -b jar 'localhost:8000/s/visit?isbn=A'
{"id":"1aIIBT6O…","visits":1,"recent":["A"],"missing":"light","has_theme":false}
$ curl -s -c jar -b jar 'localhost:8000/s/visit?isbn=B'
{"id":"1aIIBT6O…","visits":2,"recent":["A","B"],"missing":"light","has_theme":false}

has() is false for a null value, exists() true; pull() reads and deletes; forget() and flush() delete; only() and except() read a subset. Store IDs and small values, not whole models: the session is read and written on every request.

Flash data

Flash data survives exactly one more request, which is what "Saved" notices after a redirect need. now() makes data visible to the current request only, reflash() keeps all flash data for one more request, and keep(['notice']) keeps some of it:

<?php
// app/Http/Controllers/SessionDemoController.php (excerpt)
use Illuminate\Http\Request;

function flash(Request $request): array                     // GET /s/flash
{
    $request->session()->flash('notice', 'Saved');           // available on the next request only
    $request->session()->now('inline', 'This request only');

    return ['inline' => session('inline')];
}

function read(Request $request): array                      // GET /s/read
{
    if ($request->boolean('keep')) {
        $request->session()->reflash();                       // keep all flash data one more request
    }

    return ['notice' => session('notice'), 'pulled' => $request->session()->pull('visits')];
}
$ curl -s -c jar -b jar localhost:8000/s/flash
{"inline":"This request only"}
$ curl -s -c jar -b jar 'localhost:8000/s/read?keep=1'
{"notice":"Saved","pulled":2}
$ curl -s -c jar -b jar localhost:8000/s/read
{"notice":"Saved","pulled":null}
$ curl -s -c jar -b jar localhost:8000/s/read
{"notice":null,"pulled":null}

Without reflash() the notice would have disappeared on the second read. redirect()->with() and withInput() (Requests, Input and Responses) and validation errors (Validation and Form Requests) all use flash data. A flashed message is consumed by any next request in the same session, including a background fetch() or a favicon request that reaches PHP, which is the usual reason for "the notice never showed".

Regenerating and invalidating

A session ID must change whenever the privilege level changes, or an attacker who planted an ID before login owns the session after it (session fixation). regenerate() issues a new ID and keeps the data; invalidate() issues a new ID and clears the data:

<?php
// app/Http/Controllers/SessionDemoController.php (excerpt)
use Illuminate\Http\Request;

function login(Request $request): array                     // POST /s/login
{
    $before = $request->session()->getId();
    $request->session()->regenerate();                        // new ID, same data: after login

    return ['changed' => $before !== $request->session()->getId(), 'visits' => session('visits')];
}

function logout(Request $request): array                    // POST /s/logout
{
    $request->session()->invalidate();                        // new ID, data cleared
    $request->session()->regenerateToken();                   // new CSRF token

    return ['visits' => session('visits')];
}
$ curl -s -c jar -b jar -X POST localhost:8000/s/login
{"changed":true,"visits":1}
$ curl -s -c jar -b jar -X POST localhost:8000/s/logout
{"visits":null}

Auth::attempt() and Auth::login() regenerate the ID themselves, and the starter kits' logout actions call invalidate() and regenerateToken(); hand-written login code must do the same (Authentication). The demo routes skip CSRF so that curl can post.

Serialization: JSON in Laravel 13

New Laravel 13 applications set 'serialization' => 'json' in config/session.php. Session data is stored as JSON instead of PHP’s serialize() format, so a leaked APP_KEY cannot be turned into a deserialization ("gadget chain") attack. The consequence is that objects do not survive a round trip:

<?php
// app/Http/Controllers/SessionDemoController.php (excerpt)
use App\Models\Book;
use Illuminate\Http\Request;

function object(Request $request): array                    // GET /s/object
{
    $request->session()->put('when', now());
    $request->session()->put('book', Book::first());

    return ['stored' => true];
}

function objectBack(Request $request): array                // GET /s/object-back, the next request
{
    return ['when' => get_debug_type(session('when')), 'book' => get_debug_type(session('book'))];
}
$ curl -s -c jar -b jar localhost:8000/s/object
{"stored":true}
$ curl -s -c jar -b jar localhost:8000/s/object-back
{"when":"string","book":"array"}

The Carbon instance came back as an ISO 8601 string and the model as a plain array of its attributes. Store scalars and IDs, and rebuild objects on read (Carbon::parse(), Book::find()). Applications upgraded from Laravel 12 keep php unless the setting is changed, and switching it logs every user out, because existing sessions can no longer be read. The database driver stores the payload base64-encoded; decoded, a row looks like {"_token":"…","_flash":{"old":[],"new":[]},"when":"2026-10-05T20:09:21.808104Z","book":{"id":1,…}}.

Session blocking

Requests that share a session run concurrently, and each one saves the whole session it loaded when it finishes. Two overlapping requests that both write therefore lose one write. Measured with the built-in server running four workers (PHP_CLI_SERVER_WORKERS=4), a slow route (800 ms) and a fast one, both writing a key, the fast one started 200 ms after the slow one:

<?php
// routes/catalogue.php
use Illuminate\Support\Facades\Route;

Route::get('/s/slow', function () { usleep(800_000); session()->put('slow', true); return 'slow done'; });
Route::get('/s/fast', function () { session()->put('fast', true); return 'fast done'; });
Route::get('/s/slow-blocked', function () { usleep(800_000); session()->put('slow', true); return 'slow done'; })->block();
Route::get('/s/fast-blocked', function () { session()->put('fast', true); return 'fast done'; })->block();
without block():  session keys afterwards ["slow"]           fast request took 0.015 s
with block():     session keys afterwards ["slow","fast"]    fast request took 0.630 s

The slow request had loaded the session before the fast one wrote fast, then saved its own copy over it. With block($lockSeconds = 10, $waitSeconds = 10) the second request waited for the session lock (630 ms) and both writes survived. Blocking needs a cache store with atomic locks (the default database store qualifies, as do Redis and Memcached) and does not work with the cookie driver. Native PHP file sessions lock by default, which is why this problem is specific to framework session stores; use block() only on routes that write to the session, since every blocked request on the same session is serialised.

Session cache and custom drivers

$request->session()->cache() (Laravel 12) is a cache store scoped to the session: put('discount', 10, now()->plus(minutes: 5)), get(), remember(), with entries removed when the session ends. It suits per-user API responses or wizard state that should not live in the session payload itself.

A custom store implements PHP’s own SessionHandlerInterface (open, close, read, write, destroy, gc) and is registered in a service provider:

<?php
// app/Providers/AppServiceProvider.php, in boot() (from the documentation; not run here)
use App\Extensions\MongoSessionHandler;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\Facades\Session;

Session::extend('mongo', function (Application $app) {
    return new MongoSessionHandler;
});
// then SESSION_DRIVER=mongo

The handler receives and returns already-serialized strings; Laravel does the encoding. For MongoDB, the mongodb/laravel-mongodb package already provides a session driver (MongoDB: Connecting from an Application).

Cookies

Every cookie the application sets goes through EncryptCookies in the web group: it is encrypted and authenticated with APP_KEY, and incoming cookies that do not decrypt are treated as absent. A cookie written by JavaScript, or by another application on the same domain, is therefore invisible to $request->cookie() until it is excluded:

$ curl -s -b 'theme=dark' localhost:8000/s/theme              # returns $request->cookie('theme') and $_COOKIE['theme']
{"cookie":null,"raw":"dark"}

# bootstrap/app.php, in withMiddleware():  $middleware->encryptCookies(except: ['theme']);

$ curl -s -b 'theme=dark' localhost:8000/s/theme
{"cookie":"dark","raw":"dark"}

Setting cookies on a response is on Requests, Input and Responses; Cookie::queue('name', 'value', $minutes) attaches a cookie to whatever response is eventually sent, from code that has no response object. Encrypted cookies are tamper-proof, but rotating APP_KEY invalidates them and every session; list the old key in APP_PREVIOUS_KEYS to rotate without logging everyone out.

Plain PHP and Laravel side by side

Plain PHP Laravel 13

session_start(), $_SESSION['k'] = $v

StartSession middleware, session(['k' => $v])

session.save_handler (files by default)

SESSION_DRIVER (database by default)

session_regenerate_id(true)

$request->session()->regenerate(), called by Auth::login()

session_destroy() plus clearing the cookie

$request->session()->invalidate()

session.cookie_httponly, _secure, _samesite in php.ini

http_only, secure, same_site in config/session.php

session.gc_probability / gc_divisor

'lottery' => [2, 100]: 2 % of requests delete expired rows

File locking serialises concurrent requests

No locking unless the route uses block()

Flash messages written by hand

flash(), now(), reflash(), keep()

serialize() format

JSON (Laravel 13), so no objects

Every request without a session cookie creates a session, so crawlers and health checks fill the sessions table: after the curl runs on this page it held 107 rows. The lottery deletes expired rows; routes that never need a session (webhooks, APIs, /up) belong outside the web group.

Where older books and tutorials are outdated

  • file as the default driver and php artisan session:table (Stauffer ch. 14) are now database with the table in the default migration (Laravel 11), and the command is make:session-table.

  • Storing models or other objects in the session worked with PHP serialization; new Laravel 13 applications use JSON, and the objects come back as arrays or strings.

  • Session::put() from the Session facade everywhere still works, but the injected $request->session() or session() helper makes the dependency visible and testable.

  • $_SESSION and session_start() inside a Laravel application (mixing plain-PHP tutorials with the framework) have no effect on Laravel’s session, which never starts the native one.