Lesson 10 • Intermediate
Sessions & Cookies 🍪
By the end of this lesson you'll be able to remember a user across page loads — keeping login state in a secure server-side session and storing harmless preferences in a browser cookie, the right way.
What You'll Learn in This Lesson
- Start a session with session_start() and explain how PHPSESSID works
- Read and write data with the $_SESSION superglobal
- Build a login / logout pattern with isset() and session_destroy()
- Set cookies with expiry, path, secure, httponly and samesite
- Read cookies from $_COOKIE and delete them safely
- Defend against session fixation with session_regenerate_id()
$_SESSION['key'] or isset() look unfamiliar, revisit Forms & User Input first.php file.php command. Install PHP, run php -S localhost:8000 in your project folder, and open http://localhost:8000 in a browser. The Output panel under each example shows what to expect — refresh the page to watch the data persist.1️⃣ Sessions: Remembering a User
The web is stateless — by default the server forgets everything the moment a page finishes loading, so the next click looks like a brand-new stranger. A session fixes that. Calling session_start() gives the visitor a unique ID, stores that ID in a cookie called PHPSESSID, and opens a private file on the server to hold their data. You read and write that data through the $_SESSION superglobal — an array PHP makes available in every file. Because the data lives on the server, the user can't see or tamper with it.
<?php
// HTTP is "stateless" — the server forgets you the instant a page finishes
// loading. A SESSION is how PHP remembers you across page loads.
//
// session_start() does the remembering. On the FIRST visit it:
// 1. generates a unique session ID (a long random string)
// 2. sends that ID to the browser in a cookie named PHPSESSID
// 3. creates a file on the server to hold YOUR data
// On every later visit the browser sends PHPSESSID back, and PHP reopens
// the matching file — so your data is waiting for you.
session_start(); // ALWAYS the first thing, before any HTML or echo
// $_SESSION is a "superglobal" — an array PHP fills in for you, readable
// from any file once the session is started. Write to it like any array:
$_SESSION['username'] = 'alice'; // stored on the SERVER, not the browser
$_SESSION['role'] = 'admin';
echo "Saved! Your session ID is: " . session_id() . "\n";
echo "Stored username: " . $_SESSION['username'] . "\n";
?>Saved! Your session ID is: 8f3c1a9b7e2d4f60a1c5
Stored username: aliceOne rule dominates everything here: session_start() must run before any output — no HTML, no echo, not even a stray blank line before <?php. It sends an HTTP header, and headers must come first. Put it at the very top of the file, every time.
2️⃣ Reading & Removing Session Data
On a later page you call session_start() again — not to recreate the data, but to reconnect to it using the ID the browser sent back. Always guard a read with isset(), which returns true only when a key exists; reading a missing key throws a warning. To remove data you have two tools: unset($_SESSION['key']) deletes a single value, and you'll see session_destroy() below for clearing everything.
<?php
// On a LATER page (e.g. dashboard.php) you start the session again to
// reconnect to the same data — you do NOT set the values a second time.
session_start();
// Always check a key EXISTS before reading it. isset() returns true only
// if the key is present and not null — this avoids "Undefined index" warnings.
if (isset($_SESSION['username'])) {
echo "Welcome back, " . $_SESSION['username'] . "!\n"; // Welcome back, alice!
echo "Your role is: " . $_SESSION['role'] . "\n"; // Your role is: admin
} else {
echo "No one is logged in.\n";
}
// Removing data:
unset($_SESSION['role']); // delete ONE key — 'username' still remains
echo isset($_SESSION['role']) ? "role still here\n" : "role removed\n";
// session_destroy() throws away the WHOLE session (used at logout, below).
?>Welcome back, alice!
Your role is: admin
role removed3️⃣ The Login / Logout Pattern
Login state is just session data. When a login succeeds you store something that identifies the user (a user_id); protected pages then check isset($_SESSION['user_id']) to decide who gets in; logout clears it. The one line beginners skip is session_regenerate_id(true) at login — it swaps in a fresh session ID so an attacker can't hijack the session (more on this in Security below). Logout empties $_SESSION and calls session_destroy() to delete the server file.
<?php
// The classic login-state pattern: store a user id when login succeeds,
// check for it on protected pages, clear it on logout.
session_start();
function login(string $username): void {
// SECURITY: regenerate the ID the moment privileges change. This swaps
// the old session ID for a fresh one so an attacker who planted an ID
// earlier (session fixation) cannot ride your now-logged-in session.
session_regenerate_id(true); // true = delete the old session file
$_SESSION['user_id'] = 42;
$_SESSION['username'] = $username;
}
function isLoggedIn(): bool {
return isset($_SESSION['user_id']); // true once login() has run
}
function logout(): void {
$_SESSION = []; // 1) empty the data array
session_destroy(); // 2) delete the session file on the server
}
login('alice');
echo isLoggedIn() ? "Logged in as {$_SESSION['username']}\n" : "Guest\n";
logout();
session_start(); // start a fresh, empty session to check
echo isLoggedIn() ? "Still logged in\n" : "Logged out\n";
?>Logged in as alice
Logged out4️⃣ Cookies: Storing Data in the Browser
A cookie is a small key-value pair (up to about 4KB) that PHP asks the browser to store and send back on every future request. Use cookies for things that aren't secret — a theme, a chosen language, a "remember me" flag. You create one with setcookie(), and the modern form takes an options array so the security flags are clearly named. Like session_start(), it sends a header, so it must run before any output.
<?php
// A COOKIE is a small piece of data stored in the BROWSER (max ~4KB).
// The browser sends it back on every request to the same site. Cookies are
// perfect for non-secret preferences: theme, language, "remember me".
//
// setcookie() sends a Set-Cookie HEADER, so — like session_start() — it must
// run BEFORE any output. Modern signature uses an options array:
setcookie('theme', 'dark', [
'expires' => time() + 60 * 60 * 24 * 30, // now + 30 days (a UNIX timestamp)
'path' => '/', // send the cookie for the WHOLE site, not just one folder
'secure' => true, // only sent over HTTPS — never plain http
'httponly' => true, // JavaScript canNOT read it (blocks XSS theft)
'samesite' => 'Strict', // don't send on cross-site requests (blocks CSRF)
]);
setcookie('lang', 'en', [
'expires' => time() + 60 * 60 * 24 * 365, // 1 year
'path' => '/',
]);
echo "Two cookies were sent in the response headers.\n";
echo "They will appear in \$_COOKIE on the NEXT request.\n";
?>Two cookies were sent in the response headers.
They will appear in $_COOKIE on the NEXT request.Each option earns its place: expires is a UNIX timestamp for when the cookie dies (omit it and the cookie vanishes when the browser closes); path set to '/' makes the cookie apply site-wide; secure restricts it to HTTPS; httponly hides it from JavaScript; and samesite blocks it on cross-site requests. The last three are your front-line defence and you'll meet them again in Security.
5️⃣ Reading & Deleting Cookies
Here's the catch that trips everyone the first time: a cookie you just set is not in $_COOKIE on the same request. setcookie() only tells the browser to store it; the browser sends it back starting from the next request, which is when PHP fills the $_COOKIE superglobal. To read it, guard with isset() or the null-coalescing ?? operator for a fallback. To delete a cookie, re-set it with an expiry in the past.
<?php
// On the NEXT request the browser sends the cookies back, and PHP fills the
// $_COOKIE superglobal for you. (No session_start() needed for cookies.)
//
// Imagine the browser sent: theme=dark; lang=en
if (isset($_COOKIE['theme'])) {
echo "Your theme is: " . $_COOKIE['theme'] . "\n"; // Your theme is: dark
} else {
echo "No theme set — using the default.\n";
}
// A null-coalescing default (??) is the tidy way to read with a fallback:
$lang = $_COOKIE['lang'] ?? 'en'; // use 'en' if the cookie is missing
echo "Language: " . $lang . "\n"; // Language: en
// DELETE a cookie by re-setting it with an expiry time in the PAST:
setcookie('theme', '', ['expires' => time() - 3600, 'path' => '/']);
echo "The theme cookie has been told to expire.\n";
?>Your theme is: dark
Language: en
The theme cookie has been told to expire.6️⃣ Security: Fixation, httponly & secure
State is where most beginner security holes live, so internalise three habits. Session fixation: an attacker gets a victim to use a session ID the attacker already knows, then waits for the victim to log in and rides the now-authenticated session. Defeat it by calling session_regenerate_id(true) the instant a login succeeds — the ID changes, so the attacker's copy is worthless. Cookie theft: a cross-site scripting (XSS) flaw lets injected JavaScript read document.cookie — unless the cookie is httponly: true, which hides it from JavaScript entirely. Eavesdropping & CSRF: secure: true keeps the cookie off plain HTTP, and samesite: 'Strict' (or 'Lax') stops it riding along on requests from other sites. For any cookie tied to a login, set all three — and never store passwords or raw tokens in a cookie; keep secrets in the session and store only a hashed token if you must persist one.
7️⃣ Your Turn: Check Login State
Now you try. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — finish the login check. Fill each blank marked ___ , then run.
// 1) Start the session so $_SESSION works
___; // 👉 the function that starts/resumes a session
$_SESSION['user_id'] = 7; // pretend a login already happened
// 2) Read the value safely — fill in the function that tests a key exists
if (___($_SESSION['user_id'])) { // 👉 returns true only if the key is set
echo "Logged in. User #" . $_SESSION['user_id'] . "\n";
} else {
echo "Not logged in\n";
}
// ✅ Expected output:
// Logged in. User #7
?>Logged in. User #7One more. This time you set a secure cookie and read it back with a sensible default. Fill in the function name and the two security flags.
<?php
// 🎯 YOUR TURN — set ONE secure cookie, then read it back with a default.
// 1) Store the user's chosen theme for 7 days. Fill in the function name
// and the two security flags that block JS access and force HTTPS.
___('theme', 'dark', [ // 👉 the function that sends a Set-Cookie header
'expires' => time() + 60 * 60 * 24 * 7,
'path' => '/',
'secure' => ___, // 👉 true — HTTPS only
'httponly' => ___, // 👉 true — hide it from JavaScript
]);
// 2) On the next request, read it with 'light' as the fallback.
$theme = $_COOKIE['theme'] ?? ___; // 👉 the default value in quotes: "light"
echo "Theme: " . $theme . "\n";
// ✅ Expected output (first run, before the cookie comes back): Theme: light
// (after the browser returns the cookie): Theme: dark
?>Theme: lighttrue in both flag blanks and "light" as the fallback. On the very first load (before the cookie returns) the output is Theme: light.Common Errors (and the fix)
- "Warning: session_start(): Cannot start session — headers already sent" — something printed before
session_start()(orsetcookie()). The usual culprit is a blank line or space before the opening<?phptag, or anechoabove it. Move both calls to the very top and strip any whitespace before<?php. - Login "works" but the session can be hijacked — you didn't regenerate the ID. After every successful login call
session_regenerate_id(true)so the session ID changes; otherwise an attacker who fixed the ID earlier rides your logged-in session (session fixation). - A login cookie can be read by JavaScript or sent over plain HTTP — you left off the security flags. Set
'httponly' => true(blocks JavaScript / XSS theft) and'secure' => true(HTTPS only), and add'samesite' => 'Strict'to blunt CSRF. - "Undefined array key" when reading
$_SESSIONor$_COOKIE— you read a key that isn't there. Guard withisset($_SESSION['key'])or use a default:$_COOKIE['x'] ?? 'fallback'. $_COOKIEis empty right aftersetcookie()— that's expected, not a bug. The cookie only arrives on the next request. Reload the page (or move to the next one) and it will be there.
Pro Tips
- 💡 Secrets go in the session, not the cookie. The browser only ever needs the meaningless
PHPSESSID— keep usernames, roles, and tokens server-side in$_SESSION. - 💡 Regenerate on every privilege change, not just login — for example after switching to an admin area. It's cheap insurance against fixation.
- 💡 Prefer the array form of
setcookie()(PHP 7.3+). Named options like'samesite'are clearer and harder to get wrong than a long list of positional arguments.
📋 Quick Reference — Sessions & Cookies
| Syntax | Example | What It Does |
|---|---|---|
| session_start() | session_start(); | Start/resume the session (before any output) |
| $_SESSION['k'] | $_SESSION['id'] = 7; | Read / write server-side session data |
| unset() | unset($_SESSION['k']); | Remove one session variable |
| session_destroy() | session_destroy(); | Destroy the whole session (logout) |
| session_regenerate_id() | session_regenerate_id(true); | New ID — prevents session fixation |
| setcookie() | setcookie('t','dark',[...]); | Create / update / delete a cookie |
| $_COOKIE['name'] | $_COOKIE['t'] ?? 'light' | Read a cookie (next request) |
Frequently Asked Questions
Q: What is the difference between a session and a cookie?
A cookie stores data in the browser; a session stores data on the server. With a session, the only thing the browser holds is a meaningless ID (the PHPSESSID cookie) — the real data (your username, role, cart) lives in a file on the server where users cannot see or change it. Cookies are limited to about 4KB and can be read and altered by the user, so use cookies for harmless preferences (theme, language) and sessions for anything that matters, like login state.
Q: Why do I get "headers already sent" or "Cannot modify header information"?
Both session_start() and setcookie() work by sending HTTP headers, and headers must come before any page content. If even a single space, blank line, or echo runs before them, PHP has already started sending the body and the headers are locked. The fix is to call session_start() and setcookie() at the very top of the file, before any HTML or output — and make sure there is no whitespace before the opening <?php tag.
Q: What does session_regenerate_id() do and when should I call it?
It replaces the current session ID with a brand-new one while keeping the data. Call it immediately after a privilege change — most importantly right after a successful login. This defends against session fixation, where an attacker tricks a victim into using a session ID the attacker already knows; regenerating the ID at login means the attacker's ID no longer points at the logged-in session. Pass true (session_regenerate_id(true)) to also delete the old session file.
Q: What do the secure, httponly, and samesite cookie flags do?
secure tells the browser to send the cookie only over HTTPS, so it is never exposed on plain http. httponly hides the cookie from JavaScript (document.cookie), which stops a cross-site scripting (XSS) attack from stealing it. samesite controls whether the cookie is sent on requests coming from other sites — Strict or Lax helps block cross-site request forgery (CSRF). For any cookie tied to a login, set all three.
Q: Why is my $_COOKIE empty right after I call setcookie()?
Cookies are not available on the same request that sets them. setcookie() only adds a header telling the browser to store the cookie; the browser then sends it back starting from the next request, which is when PHP populates $_COOKIE. So you will see the value on a reload or the next page, not on the line directly after setcookie().
Mini-Challenge: A Page-View Counter
No code is filled in this time — just a brief and an outline. Write it yourself, serve it with php -S localhost:8000, then refresh the page a few times and watch the count climb. This is the same store-read-update loop behind every shopping cart and login on the web.
<?php
// 🎯 MINI-CHALLENGE: A page-view counter that survives reloads.
// No code is filled in — work from the steps below, then run it and refresh.
//
// 1. Start the session.
// 2. If $_SESSION['views'] is NOT set, set it to 0.
// (Tip: $_SESSION['views'] = $_SESSION['views'] ?? 0;)
// 3. Add 1 to $_SESSION['views'].
// 4. echo: "You have viewed this page X time(s)."
// 5. SECURITY: on a real login you'd also call session_regenerate_id(true)
// — add a comment saying WHY (it prevents session fixation).
//
// ✅ Expected output:
// 1st load: You have viewed this page 1 time(s).
// 2nd load: You have viewed this page 2 time(s).
// 3rd load: You have viewed this page 3 time(s).
// your code here
?>$_SESSION['views'] to 0, add 1, and echo the count. Refresh to see it grow: 1, then 2, then 3.🎉 Lesson Complete!
- ✅ HTTP is stateless; a session remembers a user via the
PHPSESSIDcookie and server-side storage - ✅
session_start()must run before any output; read/write data through$_SESSION - ✅ Guard reads with
isset(); remove withunset()orsession_destroy() - ✅ Cookies live in the browser — set them with
setcookie()and read them from$_COOKIEon the next request - ✅ Lock cookies down with
secure,httponly, andsamesite; regenerate the ID at login to stop fixation - ✅ Next lesson: Object-Oriented PHP — organise your code into reusable classes and objects
Sign up for free to track which lessons you've completed and get learning reminders.