PHP Composer Autoload PSR-4 Example (Step by Step)
Wire Composer PSR-4 autoload in PHP: map App\ to src/App/, require vendor/autoload.php, dump-autoload, and fix the usual class-not-found mistakes.
A PHP login with sessions means: check the email and password against the database with a prepared statement and password_verify(), call session_regenerate_id(true) on success, store something like $_SESSION['user_id'], then on every protected page call session_start() and redirect to the login form if that key is missing. Logout empties $_SESSION, clears the session cookie, and runs session_destroy(). You never store the raw password in the session, and you never compare passwords with === against a hash.
This note is the session layer on top of my password_hash / password_verify and PDO prepared statements posts. The hashing article shows how to store secrets; this one shows how to remember “this browser is logged in†across requests. All examples were exercised on PHP 8.4 with a SQLite in-memory database so you can paste and run them without MySQL.
HTTP is stateless. Each request arrives without memory of the last one. After a successful password check you need the server to recognise later requests from the same browser. PHP sessions do that by:
PHPSESSID).$_SESSION data on the server keyed by that id.So the cookie is only an identifier, not the user’s password or email. That is why stealing a session cookie is still dangerous — but it is far better than putting credentials in a cookie.
session_start() must run before any HTML or echo, because it sends a Set-Cookie header. A stray blank line before <?php is enough to break it with “headers already sentâ€.
<?php
declare(strict_types=1);
session_start();
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
)');
$email = 'aisha@example.co.uk';
$password = 'correct horse battery staple';
$pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)')
->execute([$email, password_hash($password, PASSWORD_DEFAULT)]);
function attempt_login(PDO $pdo, string $email, string $password): bool
{
$stmt = $pdo->prepare(
'SELECT id, email, password_hash FROM users WHERE email = ? LIMIT 1'
);
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return false;
}
// New id so an old session id cannot be reused (session fixation)
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['email'] = $user['email'];
return true;
}
var_export(attempt_login($pdo, $email, 'wrong')); // false
var_export(attempt_login($pdo, $email, $password)); // true
echo session_id(), PHP_EOL;
Representative output from the test run:
Wrong password: false
REDIRECT login.php
Right password: true
Session id after regenerate: ebfa8ccbd49621cd25108692300147bb
OK user_id=1 email=aisha@example.co.uk
Logged out; session emptied and destroyed
REDIRECT login.php
Important details:
password_verify. Putting the password in the WHERE clause only works for plaintext (or unsalted) storage — both are wrong.session_regenerate_id(true) issues a new id and deletes the old session file. That blocks session fixation: an attacker who planted a known id before login cannot keep using it after you authenticate.Every sensitive script starts the same way:
<?php
declare(strict_types=1);
session_start();
if (empty($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
// Safe to show private content
$email = $_SESSION['email'] ?? '';
?>
<!DOCTYPE html>
<html lang="en-GB">
<head><meta charset="utf-8"><title>Dashboard</title></head>
<body>
<p>Signed in as <?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?></p>
<p><a href="/logout.php">Log out</a></p>
</body>
</html>
Always exit after a redirect. Without it, PHP continues running and may leak content to clients that ignore redirects. Escape the email when printing — see htmlspecialchars to prevent XSS.
A shared require_login.php you require at the top of each protected file keeps the check in one place:
<?php
// require_login.php
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
if (empty($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
<?php
declare(strict_types=1);
session_start();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
header('Location: /login.php');
exit;
Emptying the array clears data for the current request. session_destroy() removes the server-side store. Clearing the cookie stops the browser from presenting the old id. Skipping the cookie step is a common bug: the next session_start() may recreate an empty session with the same id, which is confusing when you are testing “am I logged out?â€
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/db.php'; // returns PDO
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if ($email === '' || $password === '') {
$error = 'Enter your email and password.';
} elseif (attempt_login($pdo, $email, $password)) {
header('Location: /dashboard.php');
exit;
} else {
$error = 'Invalid email or password.';
// optional: usleep(200000) to slow brute force slightly
}
}
?>
<form method="post" action="/login.php" novalidate>
<?php if ($error): ?>
<p><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<label>Email <input type="email" name="email" required
value="<?= htmlspecialchars($_POST['email'] ?? '', ENT_QUOTES, 'UTF-8') ?>"></label>
<label>Password <input type="password" name="password" required></label>
<button type="submit">Log in</button>
</form>
Use POST, HTTPS in production, and never put the password in the sticky value. For spam on public forms (not login), a math captcha with sessions is a separate pattern.
In php.ini or before session_start():
<?php
session_set_cookie_params([
'lifetime' => 0, // until browser closes
'path' => '/',
'secure' => true, // HTTPS only
'httponly' => true, // no document.cookie access
'samesite' => 'Lax', // CSRF mitigation for many cases
]);
session_start();
httponly: JavaScript on a compromised page cannot read the session id as easily.secure: cookie only sent over HTTPS.samesite=Lax: stops the cookie on most cross-site POSTs; use Strict if you can live with stricter navigation behaviour.lifetime = 0: session cookie. “Remember me for 30 days†is a different design (usually a separate random token in a long-lived cookie, stored hashed in the database) — do not just extend the PHP session lifetime to a month without thinking about theft.Fixation: attacker sets your session id (for example via a crafted link on an old PHP config), you log in, attacker reuses that id. Defence: session_regenerate_id(true) at login (and optionally at privilege changes).
Hijacking: attacker steals the cookie (XSS, malware, network on plain HTTP). Defences: HTTPS, HttpOnly, short idle timeouts, regenerate periodically, escape output so XSS is harder (htmlspecialchars guide).
Neither is fixed by hashing passwords alone — but weak passwords make everything worse. Stick to password_hash / password_verify.
= (plaintext era).session_regenerate_id(true) after login.session_start() after output.exit after Location headers.$_SESSION.unset($_SESSION['user_id']) without destroying / clearing the cookie.Yes, on every request that reads or writes $_SESSION, including the login form, dashboard and logout. If the session is already active, session_status() helps you avoid starting twice when using a shared bootstrap.
You can, but then you must sign or look up a server-side token; a raw user_id=3 cookie is trivial to forge. PHP sessions already give you a random id mapped to server data. Prefer that for ordinary logins.
Minimum: user_id. Optional: display name, role, login time. Avoid large objects and anything secret you do not need on every request. Load fresh permissions from the database when authorising sensitive actions.
No. Sessions identify the browser; CSRF tokens prove the form was submitted from your page. For state-changing POSTs, add a per-session token and check it. SameSite=Lax or Strict cookies help but are not a complete CSRF strategy for every app.
Usual causes: different cookie path or domain, secure cookie on HTTP localhost, session files garbage-collected, or a regenerating id you did not follow with a new cookie. Check session_get_cookie_params() and whether you call session_start() before any output.
Hashing proves the password at login time. The session proves the browser is still the one that passed that check. See the full password_hash example for register/login hashing details, including rehashing.
A session that lives forever on a shared computer is risky. A simple idle timeout:
<?php
declare(strict_types=1);
session_start();
$idleSeconds = 60 * 30; // 30 minutes
$now = time();
if (!empty($_SESSION['user_id'])) {
$last = $_SESSION['last_active'] ?? $now;
if ($now - $last > $idleSeconds) {
$_SESSION = [];
session_destroy();
header('Location: /login.php?reason=idle');
exit;
}
$_SESSION['last_active'] = $now;
}
For changing email or password, require the current password again even if the session is valid. That way a borrowed laptop left unlocked cannot silently take over the account. Store a password_hash check result only for that request; do not weaken the main login flow.
People often stash $_SESSION['flash'] = 'Saved' after a POST-redirect-GET. That is fine. Clear the flash after reading it once. Do not put HTML from the user into the flash without escaping on display. Prefer fixed message keys ('profile_updated') mapped to trusted strings in the template.
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
// validate email format + password length (see form validation post)
$hash = password_hash($password, PASSWORD_DEFAULT);
try {
$pdo->prepare('INSERT INTO users (email, password_hash) VALUES (?, ?)')
->execute([$email, $hash]);
} catch (PDOException $e) {
// unique constraint — show "email taken" without leaking other errors
}
// optionally auto-login: attempt_login(...) then redirect
}
Full hashing commentary lives in the password_hash post. The session article’s job is only to remember the user id after that check succeeds.
You can drive the happy path in PHPUnit or a CLI script by calling attempt_login directly, then asserting $_SESSION['user_id'] is set and that a second call with a wrong password leaves a blank session. For cookie behaviour you need a real HTTP client against php -S. Keep secrets out of fixtures; use throwaway SQLite as in the tested example above.
By default PHP stores sessions in files under a system temp directory. That works on a single server. On two load-balanced machines without a sticky session, a user may hit server B which does not have server A’s session file — they look logged out at random. Fixes:
Configure with session.save_handler and session.save_path. The login logic in this article stays the same; only where $_SESSION is persisted changes. For a student project on one VPS, files are fine.
session_name('MFSESSID') before session_start() avoids clashing with other apps on the same domain. Still set HttpOnly and Secure. Do not put personal data in the cookie name or value.
If you regenerate the session id on every request, multiple tabs can race and log the user out. Prefer regenerating on login, privilege change, and maybe every N minutes — not on every asset request. The tested example regenerates once on successful attempt_login, which is the usual sweet spot.
/login.php. PHP runs session_start(), may issue a new empty session cookie, shows the form.attempt_login runs the prepared SELECT, password_verify succeeds, session_regenerate_id(true) swaps the id, user_id is stored, redirect to /dashboard.php./dashboard.php with the new cookie. require_login sees user_id, page renders with escaped email./logout.php. Session array cleared, cookie expired, session_destroy, redirect to login.user_id check and returns to login.If step 2 skipped regenerate, an attacker who fixed the id in step 1 could skip the password and jump to step 3. That is why regenerate belongs next to the successful verify, not later “when you rememberâ€.
<?php
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['role'] = $user['role']; // 'member' or 'admin'
Check role on admin routes. Still re-load role from the database for destructive actions so a demoted admin does not keep powers until logout. Sessions are a cache of identity, not the authoritative permissions store.
Login forms rarely need a math captcha if you rate-limit by IP and account. Public contact forms are different — bots love them. Keep the session login path lean; reuse session machinery for captcha answers on other forms without mixing the user_id key with captcha keys.
Start the session first, verify the hash, regenerate the id, then protect every private page with the same empty-user_id check. That is the whole login story in plain PHP.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Wire Composer PSR-4 autoload in PHP: map App\ to src/App/, require vendor/autoload.php, dump-autoload, and fix the usual class-not-found mistakes.
Use Europe/London in PHP the safe way: date_default_timezone_set, DateTimeImmutable, GMT vs BST, store UTC, display dd/mm/YYYY, and avoid DST surprises.
Validate PHP uploads with UPLOAD_ERR_OK, finfo MIME detection, size limits and random names — and ignore $_FILES type and the original filename.