Week 5: Sessions, Cookies & File I/O

Every page so far has been stateless — each request starts from nothing. This week adds memory across requests: sessions and cookies for tracking a visitor, and the filesystem functions for reading, writing and safely accepting uploaded files. By the end you'll have a working session-gated page, the last piece before real persistence in MySQL.

Module 5 of 28 Week 5 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Use sessions & cookies to persist state across requests, including flash messages
  • Read, write & append to files safely with PHP's filesystem functions
  • Accept and validate file uploads through $_FILES

1. Sessions

HTTP is stateless — by default, PHP has no way to know that two requests came from the same visitor. A session fixes this: PHP generates a unique session ID, sends it to the browser as a cookie, and stores server-side data keyed by that ID:

session-example.php
<?php
declare(strict_types=1);

session_start(); // must run before any output

$_SESSION['visits'] = ($_SESSION['visits'] ?? 0) + 1;
$_SESSION['user_id'] = 42;

echo "You've visited this page {$_SESSION['visits']} times.";

session_start() either resumes an existing session (by reading the session cookie from the request) or begins a new one. Once called, $_SESSION behaves like a regular associative array, except its contents survive between requests — PHP stores them server-side (by default, as a file in its session save path) and only sends the ID itself to the browser.

session_start() must come before any output

It sends an HTTP header to set the session cookie, and headers must be sent before any HTML, whitespace, or even a stray blank line before <?php. "Headers already sent" is one of the most common early PHP errors — it means something printed before session_start() ran.

To end a session — on logout, for example — clear the data and destroy it:

logout.php
<?php
session_start();
$_SESSION = [];
session_destroy();
setcookie(session_name(), '', time() - 3600); // expire the session cookie

2. Cookies & Flash Messages

A cookie is a small piece of data stored in the browser and sent back with every subsequent request to the same domain. You've been using them indirectly through sessions; setcookie() sets your own directly:

cookies.php
<?php
setcookie(
    name: 'theme',
    value: 'dark',
    expires_or_options: time() + (86400 * 30), // 30 days
    path: '/',
    secure: true,      // only sent over HTTPS
    httponly: true,    // inaccessible to JavaScript -- blocks a class of XSS
);

$theme = $_COOKIE['theme'] ?? 'light';

A flash message is a session value meant to be read exactly once — a confirmation like "Item added to cart" that should appear after a redirect, then disappear on the next page load even if the user refreshes:

flash.php
<?php
session_start();

function flash(string $key, ?string $message = null): ?string
{
    if ($message !== null) {
        $_SESSION['flash'][$key] = $message;
        return null;
    }

    $value = $_SESSION['flash'][$key] ?? null;
    unset($_SESSION['flash'][$key]); // read once, then gone
    return $value;
}

// Setting it, e.g. right before a redirect after adding to cart:
flash('success', 'Item added to cart!');

// Reading it on the next page load:
if ($message = flash('success')) {
    echo "<p class='success'>" . htmlspecialchars($message) . "</p>";
}

3. Reading & Writing Files

PHP's filesystem functions cover reading, writing and appending. The simplest cases use file_get_contents() and file_put_contents():

file-io.php
<?php
declare(strict_types=1);

$logPath = __DIR__ . '/storage/app.log';

// Write (overwrite)
file_put_contents($logPath, "Server started\n");

// Append
file_put_contents($logPath, date('c') . " - request handled\n", FILE_APPEND);

// Read the whole file
$contents = file_get_contents($logPath);

// Read line by line without loading the whole file into memory
$handle = fopen($logPath, 'r');
while (($line = fgets($handle)) !== false) {
    echo trim($line) . "\n";
}
fclose($handle);

file_get_contents()/file_put_contents() are simplest for small files. For large files, fopen()/fgets()/ fclose() stream line by line instead of loading everything into memory at once.

Always check that filesystem calls succeeded

file_put_contents() returns false on failure (permissions, disk full, missing directory) rather than throwing. Check the return value, or wrap the call site in error handling — a silently-failed write is a nasty bug to track down later.

4. File Uploads

A form with enctype="multipart/form-data" can include file inputs; PHP populates $_FILES with metadata about each uploaded file:

upload.php
<?php
declare(strict_types=1);

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['avatar'])) {
    $file = $_FILES['avatar'];

    if ($file['error'] !== UPLOAD_ERR_OK) {
        die('Upload failed with error code ' . $file['error']);
    }

    $allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
    $mimeType = mime_content_type($file['tmp_name']); // check the real content, not the filename

    if (!in_array($mimeType, $allowedTypes, true)) {
        die('Only JPEG, PNG or WebP images are allowed.');
    }

    if ($file['size'] > 2 * 1024 * 1024) { // 2MB
        die('File too large.');
    }

    $extension = pathinfo($file['name'], PATHINFO_EXTENSION);
    $safeName = bin2hex(random_bytes(16)) . '.' . $extension; // never trust the original filename
    $destination = __DIR__ . '/storage/uploads/' . $safeName;

    move_uploaded_file($file['tmp_name'], $destination);
    echo "Uploaded as {$safeName}";
}
?>
<form method="post" enctype="multipart/form-data">
  <input type="file" name="avatar" accept="image/*">
  <button type="submit">Upload</button>
</form>

move_uploaded_file() is the correct way to relocate an uploaded file — it verifies the file actually came from a genuine upload before moving it, unlike a plain rename(). Checking the real MIME type with mime_content_type() (which inspects file content) matters because the client-supplied $file['type'] and original filename are both fully attacker-controlled and prove nothing on their own.

5. A Session-Gated Page

Combining this week's pieces: a login form that sets a session flag, and a protected page that checks it before rendering anything:

protected.php
<?php
declare(strict_types=1);
session_start();

if (empty($_SESSION['logged_in'])) {
    header('Location: /login.php');
    exit; // always exit after a redirect -- code below would still run otherwise
}

echo "Welcome back, user #{$_SESSION['user_id']}!";
Real password checking arrives in Week 8

This page checks a session flag, but nothing here verifies a password yet — that needs password_hash()/password_verify() and, realistically, a database of users, both covered in Week 8 once MySQL is in place. Right now the goal is understanding the session mechanics: setting a flag on login, checking it on every protected page, and clearing it on logout.

6. Hands-on Exercise

Hands-on

Build a session-gated note-taking app

Combine sessions, flash messages and file I/O into a small, working app.

Requirements:

  1. Create login.php with a form that accepts any username; on submit, set $_SESSION['logged_in'] = true and $_SESSION['username'], then redirect to notes.php.
  2. notes.php must redirect back to login.php if $_SESSION['logged_in'] isn't set.
  3. On notes.php, accept a POST'd note and append it (with a timestamp) to a per-user file at storage/notes/{username}.txt, then set a flash message "Note saved!" and redirect back to itself (the POST-redirect-GET pattern, avoiding a duplicate save on refresh).
  4. Display the flash message once if present, then list every previously saved note read back from that user's file.
  5. Add a logout.php that destroys the session and redirects to login.php.
Hint

Redirecting after a POST before rendering anything (rather than rendering the result directly) is the "POST-redirect-GET" pattern — it's exactly why refreshing after a normal form submission doesn't usually resubmit the form.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Where does the actual data in $_SESSION get stored?

Server-side — by default as a file in PHP's session save path. Only the session ID (a random token) is sent to the browser as a cookie. This matters for security: the browser never holds the session's actual contents, only a reference to them, so a user can't read or tamper with their own session data directly.

Q2

Why must session_start() run before any HTML or other output?

It works by sending an HTTP Set-Cookie header, and HTTP headers must be sent before the response body. Any output before it — even a stray blank line outside <?php ?> tags — counts as the start of the body and triggers a "headers already sent" error.

Q3

Why check the uploaded file's real MIME type with mime_content_type() instead of trusting $_FILES['avatar']['type']?

$_FILES[...]['type'] is read directly from a header the client sent — a browser or script uploading a malicious file can set it to anything, including image/png on a file that isn't actually an image. mime_content_type() inspects the file's actual bytes on the server, which is far harder to spoof.

Q4

Why does protected.php call exit; immediately after header('Location: ...')?

header() only queues an HTTP header — it does not stop script execution. Without exit, every line after the redirect still runs on the server, meaning the "protected" content below it would still be computed (and, if there were any output before the header call, potentially even sent) despite the redirect. exit guarantees nothing further executes.