The Handshake: What session_start() Does Before Your Code Runs
HTTP has no memory. Each request reaches the server as a stranger, and a server that cannot tell one visitor from another cannot log anyone in. PHP's answer is the session, and the entire mechanism is a handshake between three participants: a cookie on the client, an identifier, and a file on the server.
On the first request no session cookie exists. Calling session_start() does four things: it generates a session ID (128 bits of entropy by default), it sends Set-Cookie: PHPSESSID=<id>; path=/ in the response headers, it opens the server-side storage for that ID, and it fills $_SESSION with whatever was stored there. On every later request the browser replays Cookie: PHPSESSID=<id>, session_start() loads that ID's data, and $_SESSION reappears exactly as you left it. The cookie carries only the key; the data lives on the server — a file named sess_<id> under session.save_path (on Debian, /var/lib/php/sessions/). That split is why moving an app to a second server breaks sessions until you share storage or pin users to one node.
session_start(); // begin or resume the session
$_SESSION['user_id'] = 42; // write
echo $_SESSION['user_id']; // read now and on every later request
session_status() reports which of three states you are in — PHP_SESSION_NONE (not started), PHP_SESSION_ACTIVE (open), PHP_SESSION_DISABLED (turned off in php.ini) — which is how library code safely joins a session the framework may already have opened:
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
Under the CLI SAPI there are no cookies and no headers to send, so a session in a cron script is ephemeral — fine for exercising code, useless for reproducing a login flow.
Two failure modes dominate session bug reports:
Headers already sent. session_start() must run before any output flushes. A BOM, stray whitespace, or an early echo produces Warning: session_start(): Session cannot be started after headers have already been sent, and $_SESSION stays empty for the request. The fix is not ob_start() sprinkled everywhere — it is calling session_start() at the top of your front controller so all output flows through one place.
Starting twice in one request. Harmless but useless; if you only need to read, session_start(['read_and_close' => true]) loads the data and immediately releases the storage lock. Holding that lock longer than necessary is the cause of the concurrency problem in the last chapter of this topic.
The settings that matter live in php.ini or can be passed per-request to session_start():
| Option | Default | The question it answers |
|---|---|---|
session.name |
PHPSESSID |
what the cookie is called |
session.use_only_cookies |
1 |
ignore ?PHPSESSID= in the URL |
session.use_strict_mode |
0 |
accept uninitialized IDs? (turn on) |
session.cookie_httponly |
0 |
can JavaScript read the cookie? |
session.cookie_secure |
0 |
send only over HTTPS? |
Notice that strict mode and httponly default off. That is a deliberate back-compatibility decision, and hardening those defaults is the entire subject of Secure Session Settings.
session_start() returns true on success and false on storage failure; a wrong save_path or a dead Redis shows up as session_start(): Failed to read session data: user (path: /var/lib/php/sessions) while $_SESSION silently starts empty — so check the return value anywhere a missing session means a broken page.
"How does PHP know who I am across requests?" is the most common session question in PHP interviews. Answer with the handshake — server-generated ID in a cookie, data on the server, session_start() as the bridge — and you have already said more than most candidates.
$_SESSION in Practice: What to Store and What Breaks Serialization
$_SESSION is an ordinary PHP array with one unusual property: PHP loads it at request start and writes the whole thing back at request end. Anything a normal array can hold works — strings, ints, floats, bools, nested arrays, and objects whose classes can be autoloaded when the session is read back.
session_start();
$_SESSION['cart'] = ['sku' => 'A-100', 'qty' => 2]; // arrays: fine
$_SESSION['user'] = new User(42); // objects: class must be loadable
The storage format is PHP's serializer, controlled by session.serialize_handler. The legacy php handler is still the ini default; php_serialize (plain serialize() of the whole array) is what modern frameworks set, and the two formats are mutually unreadable — flip the handler on an existing install and every user's session comes back as garbage. Change it only on a fresh session store.
Serialization also draws the hard lines:
- Closures cannot be stored. The failure is delayed and confusing: assignment succeeds, then at request end the write throws
Exception: Serialization of 'Closure' is not allowed. - Resources cannot be stored. A PDO handle, file handle, or cURL resource fails at write-back or corrupts the data.
- Objects need their class. Storing an entity object means every code path that reads the session must be able to autoload that exact class — rename the namespace and old sessions fatal with
Class "..." not found.
This is why the durable convention is to store identifiers and scalars (user_id, roles, flash messages) and hydrate rich objects from the database after the session loads.
Key hygiene matters because authenticated and unauthenticated data share one flat namespace. Prefix or group keys (cart, auth.user_id, flash), and clear the right scope at privilege boundaries: on login, $_SESSION = [] before setting the fresh identity so a pre-login cart item or stale role cannot survive the transition.
Lifecycle calls, side by side:
| Call | Effect |
|---|---|
unset($_SESSION['k']) |
remove one key; the shrunken array is persisted at request end |
$_SESSION = [] |
empty the in-memory array; persisted as empty |
session_unset() |
legacy free-all helper; redundant now |
session_destroy() |
delete the server-side record for this ID |
session_regenerate_id(true) |
issue a new ID and delete the old record |
You never "save" a session — the write-back is automatic. And session_destroy() is narrower than its name suggests: it removes the server file, but does not empty $_SESSION in memory and does not expire the cookie. A correct logout does all three; you will build that exact sequence in Secure Session Settings.
Where the bytes go is controlled by session.save_handler (default files) and session.save_path. The files handler writes one file per session; save_path accepts "2;/var/lib/php/sessions" to spread sessions across two levels of subdirectories, which matters past roughly a hundred thousand files in one directory. Multi-server setups switch the handler to redis, memcached, or pdo_mysql — one config line, same $_SESSION API.
Two functions complete the picture. session_id() returns the current ID once the session is open, and calling session_id($someId) before session_start() adopts that ID — useful for migrating users onto a new session store, and dangerous if the ID ever comes from a URL parameter, because that is the fixation vector neutralized in the next topic. session_name() does the same for the cookie name: two apps on one domain that share storage set session_name('APP2SESSID') and hold two independent sessions in a single browser.
Locks, Lifetime, and session_write_close(): Why Two Tabs Feel Slow
Your account page fires three parallel AJAX calls, each running session_start(). One request wins the session lock; the other two block waiting for it, so what should be concurrent turns into a queue. With the default files handler, PHP locks the session file for the entire request — reads, queries, and rendering all happen while other requests wait. Ten parallel calls on a slow endpoint can multiply a page's latency by an order of magnitude, and nothing in the logs says why.
The fix is to stop touching $_SESSION as soon as you are done with it:
session_start();
$uid = $_SESSION['user_id'];
session_write_close(); // lock released; $_SESSION stays readable in memory
// heavy read-only work below: queries, API calls, rendering
After session_write_close(), writes to $_SESSION are silently discarded — no warning, no error, just data that vanishes at request end. That is the trap: a helper deep in the call stack that "saves" state after the close loses it invisibly. For read-only requests, session_start(['read_and_close' => true]) does open-and-release in one call.
Lifetime is governed by two clocks that must agree:
| Clock | Setting | Default | Meaning |
|---|---|---|---|
| Cookie | session.cookie_lifetime |
0 |
0 = browser-session cookie, deleted when the browser closes |
| Server | session.gc_maxlifetime |
1440 |
idle seconds before garbage collection may delete the data |
When they disagree, users get "random" logouts: the cookie is still alive but the server data was collected, so session_start() succeeds with an empty $_SESSION. The classic report is "everyone is logged out after exactly 24 minutes" — 1440 seconds of inactivity and nothing more. Set gc_maxlifetime to your intended idle window, and make sure it is applied on every server that runs the collector.
Garbage collection itself is probabilistic: each request rolls dice with odds gc_probability / gc_divisor (defaults 1 and 100 — about a 1% chance) and sweeps everything older than gc_maxlifetime. That design buries the cost across requests, but it also means an unlucky sweep can make one random request slow, and on shared hosting a second application in the same save_path with a shorter lifetime can delete your users' sessions out from under you. Production setups set gc_probability=0 and run a cron sweep — find /var/lib/php/sessions -name 'sess_*' -mtime +1 -delete — so cleanup is scheduled, not random.
One more property interviewers probe: gc_maxlifetime resets on every request because the file's mtime is touched, so a cookie that keeps being replayed keeps its data alive forever. An idle timeout is therefore not an absolute timeout. Enforcing "re-login every 12 hours regardless of activity" requires tracking something like $_SESSION['auth_at'] and comparing on each request — the exact hardening pattern you will assemble in Secure Session Settings.
Cookie scoping closes the loop. session.cookie_path (default /) decides which URLs replay the cookie; session.cookie_domain lets one session span app.example.com and api.example.com — which is also how a sibling subdomain's XSS widens the blast radius. session.cookie_samesite (PHP 7.3+) should be Lax at minimum: it keeps the cookie off cross-site POSTs and is one layer of the CSRF defense. All three are also settable per start:
session_start([
'cookie_lifetime' => 3600,
'cookie_samesite' => 'Lax',
'cookie_secure' => true,
]);
Passing the options array on every start is safer than trusting php.ini, because the ini file quietly differs between your laptop and production.
If your page keeps feeling slow with one browser tab, check for concurrent session_start() calls first. "Why do my parallel requests serialize?" — because the session is locked for the request's duration, and the cure is session_write_close() the moment writes are done.
Quiz
1. What exactly does the PHPSESSID cookie sent to the browser contain?
2. Your logout handler calls session_destroy() and then redirects. What is still left behind?
3. A page fires four parallel AJAX requests that each call session_start(), and latency multiplies. What is the cause and the standard fix?
4. Users report being logged out after exactly 24 minutes of inactivity even though their cookie is still valid. What is happening?
Flashcards
Question
What does session_start() do on the first request versus subsequent requests?
Click to reveal answer
Answer
First request: generates a session ID, sends it as the PHPSESSID cookie (Set-Cookie header), opens server-side storage, and populates $_SESSION. Later requests: replays the cookie, session_start() loads the matching server record (sess_<id> under session.save_path by default), and $_SESSION reappears. The cookie carries only the ID; data stays on the server.
Question
Why can't you store a closure or a PDO connection in $_SESSION?
Click to reveal answer
Answer
Session data is serialized at request end. Closures throw 'Serialization of Closure is not allowed' at write-back (not at assignment), and resources like PDO handles cannot be serialized at all. The convention is to store scalars and identifiers, then hydrate objects from the database after the session loads.
Question
What are the three steps of a correct logout?
Click to reveal answer
Answer
1) session_destroy() to delete the server-side record, 2) $_SESSION = [] to clear the in-memory array, 3) expire the cookie with setcookie(session_name(), '', time() - 42000, ...) using the same path and domain. session_destroy() alone leaves the array and the cookie behind.
Question
What does session_write_close() do, and what is the trap after calling it?
Click to reveal answer
Answer
It writes $_SESSION back to storage and releases the per-session lock so parallel requests stop queueing behind this one. The trap: subsequent writes to $_SESSION are silently discarded — no warning — so any code that saves state after the close loses it invisibly.
Question
How does PHP's session garbage collection work, and why do production setups disable it?
Click to reveal answer
Answer
Probabilistic: each request has a gc_probability/gc_divisor chance (defaults 1/100 ≈ 1%) to sweep records older than gc_maxlifetime (default 1440s). Production sets gc_probability=0 and runs a cron sweep instead, because random sweeps make individual requests slow, and shared save_path with other apps can delete sessions on a foreign schedule.
Revision Notes
Key Takeaways
- 1. session_start() bridges a stateless protocol: it exchanges a server-generated ID in a cookie for server-side data, populating $_SESSION on every request
- 2. The cookie never carries user data — only the identifier — which is why multi-server deployments need shared storage (Redis, Memcached, DB) or sticky routing
- 3. $_SESSION is serialized at request end: closures throw at write-back, resources fail, and renamed classes fatal old sessions — store scalars and hydrate models
- 4. The files handler locks the session for the entire request; concurrent AJAX calls queue behind each other until you session_write_close() or open read_and_close
- 5. Cookie lifetime and gc_maxlifetime are independent clocks: when gc_maxlifetime (1440s default) outlives nothing but the cookie survives, users get timed out at exactly that interval
Interview Tips
- • "How does PHP keep state across requests?" — walk the handshake: Set-Cookie with a random ID, data in sess_<id> on the server, $_SESSION loaded by session_start(); explicitly note the cookie holds no data
- • "Your page got slow with AJAX calls. What happened?" — the per-session lock serializes concurrent requests; show session_write_close() after the last write and read_and_close for GET-style endpoints
- • "Write a correct logout" — the three steps: destroy server data, empty $_SESSION, expire the cookie with matching name/path/domain; calling out that session_destroy() alone is incomplete earns the point
- • "Idle timeout versus absolute timeout" — gc_maxlifetime only sees idle time because mtime resets each request; absolute windows require storing an auth timestamp in the session and checking it per request
Cheat Sheet
Session Start and Configuration
Handshake: browser sends PHPSESSID cookie → session_start() loads sess_<id> from session.save_path → $_SESSION populated → written back automatically at request end. Cookie = ID only; data = server-side.
Options that matter:
| Option | Default | Action |
|---|---|---|
session.use_strict_mode |
0 | set 1 — refuse uninitialized IDs |
session.cookie_httponly |
0 | set 1 — no JS access |
session.cookie_secure |
0 | set 1 behind HTTPS |
session.cookie_lifetime |
0 | 0 = until browser closes |
session.gc_maxlifetime |
1440 | server-side idle window (seconds) |
Read-only request:
session_start(['read_and_close' => true]);
$uid = $_SESSION['user_id']; // writes after this are lost
Concurrency: the handler locks the session for the whole request — read what you need, then session_write_close() before slow work.
Gotchas: double-start needs the same options; session_destroy() clears server data only (also empty $_SESSION and expire the cookie); php vs php_serialize handlers are mutually unreadable; closures/resources cannot be stored.