User Accounts
SQLite, password hashing, and an auth guard. Real accounts in 50 lines.
You will learn
- Store user data in SQLite with PDO
- Hash passwords properly (never store plaintext)
- Build register and login forms
- Guard pages so only logged-in users can access them
The problem
Sessions remember this browser, but they don't know who is using it. Anyone who opens your app can see anyone else's data. You need real user accounts: a username, a password, and a way to prove "I am who I say I am."
Step 1: A database
You need a place to store users. SQLite is perfect for this — it's a database in a single file. No server to install, no credentials to configure. PHP includes PDO (PHP Data Objects) for talking to databases.
// src/Learn/DB.php — open the database and create tables
$pdo = new \PDO('sqlite:' . __DIR__ . '/../../storage/learn.db');
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$pdo->query('PRAGMA journal_mode = WAL');
$pdo->query('PRAGMA foreign_keys = ON');
$pdo->query("CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
)");
WAL mode lets multiple coroutines read the database simultaneously (important for ZealPHP).
foreign_keys enforces data integrity when we add notes in the next lesson.
Step 2: Register a user
The key insight: never store passwords as plaintext. PHP's password_hash()
generates a one-way hash that can't be reversed. Even if someone steals your database, they can't
read the passwords.
// src/Learn/Auth.php — register
public static function register(\PDO $db, string $username, string $password): ?int
{
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $db->prepare(
'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)'
);
$stmt->execute([$username, $hash, time()]);
return (int) $db->lastInsertId();
}
Think of password_hash() like a safe with a one-way lock. You put the
password in, the safe locks, and even you can't open it to see what's inside. But you can check
whether a new password matches the one inside — that's password_verify().
Step 3: Log in
// src/Learn/Auth.php — login
public static function login(\PDO $db, string $username, string $password): ?int
{
$stmt = $db->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return null;
}
return (int) $user['id'];
}
password_verify() checks whether the password matches the hash without ever decrypting it.
If it matches, store the user ID in the session — now the server knows who you are on every request.
Step 4: The auth guard
Any page that needs a logged-in user checks the session at the top:
$user = Auth::currentUser();
if (!$user) {
// Show login form
} else {
// Show the protected content
}
Auth::currentUser() reads $g->session['user_id'], looks up the user in
SQLite, and returns the user row or null. If the session has a stale user_id (e.g.,
after a database reset), it returns null too.
Architecture: proper OOP
Notice how the auth logic lives in src/Learn/Auth.php — a proper class, autoloaded
via Composer. The API endpoint (api/learn/register.php) is a thin wrapper:
// api/learn/register.php — thin endpoint
$register = function () {
$creds = Auth::readCredentials($this);
$userId = Auth::register(DB::open(), $creds['username'], $creds['password']);
// ... set session, redirect
};
Business logic in src/, endpoints in api/. The endpoint delegates;
the class does the work. This pattern scales — your API files stay under 20 lines each.
Pick a username and password. This creates a real account stored in SQLite. You'll use it in the next three lessons to save notes and chat with the AI.
Already have an account?
Challenge: change password
Build a "change password" feature. You'll need: a form with old password and new password fields, an endpoint that verifies the old password with password_verify(), then updates the hash with password_hash().
Hint 1
Use a prepared UPDATE statement: UPDATE users SET password_hash = ? WHERE id = ?
Hint 2
Always verify the old password first — never trust the client to send only valid requests
Key Takeaways
- SQLite + PDO gives you a full database in a single file — no server setup
password_hash()andpassword_verify()handle passwords safely- Store the user ID in
$g->sessionafter login — the session cookie handles the rest - Business logic in
src/classes, thin endpoint wrappers inapi/