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
Source: Laravel — Session Configuration
| Driver | Use |
|---|---|
|
The default since Laravel 11. Works on one or many servers sharing a database; also records |
|
The usual choice under load: fast, and Redis expires keys itself. Needs |
|
|
|
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. |
|
|
Source: Laravel — Driver Prerequisites
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"}
Source: Laravel — X-XSRF-TOKEN
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}
Source: Laravel — Flash Data
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
Source: Laravel — Session Blocking
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"}
Source: Laravel — Cookies and Encryption
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
File locking serialises concurrent requests |
No locking unless the route uses |
Flash messages written by hand |
|
|
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
-
fileas the default driver andphp artisan session:table(Stauffer ch. 14) are nowdatabasewith the table in the default migration (Laravel 11), and the command ismake: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 theSessionfacade everywhere still works, but the injected$request->session()orsession()helper makes the dependency visible and testable. -
$_SESSIONandsession_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.