A PHP login system is where most of this series finally comes together โ a form collects details, a database stores them, a hash protects the password, and a session remembers who is logged in from one page to the next. This lesson builds a working one from scratch: registration, login, a page only members can reach, and a logout that actually logs people out. Four small files, and every decision in them explained.
The table it needs
Every PHP login system starts with somewhere to put people. Run this once in phpMyAdmin, in the same flow_demo database the MySQL lesson used:
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(120) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Three things there are deliberate. The column is called password_hash, not password โ the name is a reminder to everyone who reads it later. It is VARCHAR(255), because today's hash is 60 characters and tomorrow's will not be. And UNIQUE on username and email means the database refuses duplicates even if your PHP check somehow misses one.
Registration
The first half of a PHP login system is registration. The handler validates, checks nobody has that name already, hashes the password and inserts:
<?php
require "db.php";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$username = trim($_POST["username"] ?? "");
$email = trim($_POST["email"] ?? "");
$password = $_POST["password"] ?? "";
if ($username === "") {
$errors[] = "Username is required.";
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = "That email address does not look valid.";
}
if (strlen($password) < 8) {
$errors[] = "Password must be at least 8 characters.";
}
if (!$errors) {
$stmt = $pdo->prepare("SELECT id FROM users WHERE username = ? OR email = ?");
$stmt->execute([$username, $email]);
if ($stmt->fetch()) {
$errors[] = "That username or email is already registered.";
} else {
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
"INSERT INTO users (username, email, password_hash) VALUES (?, ?, ?)"
);
$stmt->execute([$username, $email, $hash]);
$success = "Account created. You can log in now.";
}
}
}
?>
Output

Note what is not here. The password is never trimmed โ spaces are legitimate characters and trimming them silently changes what the user typed. It is never escaped or sanitised either, because it is going straight into password_hash() and coming out as safe hex. And the length check is on strlen() before hashing, not after: every hash is 60 characters regardless of the input.
filter_var() with FILTER_VALIDATE_EMAIL is worth knowing generally. It is a built-in validator that handles the awkward parts of the email format better than any regex you would write by hand.
What the database actually holds
Open the users table in phpMyAdmin after registering, because this row is the point of the whole exercise:
Output

The password is not there. What is there is a 60-character string starting $2y$ โ the bcrypt marker, then the cost, then the salt and hash together.
If this database were stolen tomorrow, nobody's password would be in it. That is the entire reason we hash, and it is worth looking at once rather than taking on trust.
Logging in
The other half of the PHP login system is the mirror image: find the row, verify the password against the stored hash, and record the result in the session:
<?php
session_start();
require "db.php";
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$username = trim($_POST["username"] ?? "");
$password = $_POST["password"] ?? "";
$stmt = $pdo->prepare("SELECT id, username, password_hash FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
// One message for both cases. Never say which half was wrong.
if (!$user || !password_verify($password, $user["password_hash"])) {
$error = "Wrong username or password.";
} else {
session_regenerate_id(true);
$_SESSION["user_id"] = $user["id"];
$_SESSION["username"] = $user["username"];
header("Location: 03-dashboard.php");
exit;
}
}
?>
Output

Three lines here carry more weight than they look.
The single error message. "No such user" and "Wrong password" as separate messages hand an attacker a way to discover which usernames exist. One message for both keeps that shut.
session_regenerate_id(true). The visitor already had a session id before logging in, and if someone managed to plant a known id in their browser, that id would now be an authenticated session. Regenerating issues a fresh one at the moment privileges change, which makes the old one worthless. This is session fixation, and this one line is the whole defence.
exit after header(). A redirect header does not stop the script. Without exit, PHP keeps running and can leak the rest of the page into a response the browser is about to abandon.


Loading comments...