Skip to content

PHP Tutorial Practical PHP Projects

Project 6: User Registration and Login

Lesson 352 of 369 2 min read By Deepak Paswan Updated Oct 5, 2026

Level: intermediate. This project combines the whole Authentication chapter into a working system: register, log in, a protected dashboard and logout.

Files

Text
PLAIN
auth-app/
├── db.php
├── functions.php       h(), csrfToken(), csrfCheck(), requireLogin()
├── AuthService.php     all rules
├── register.php
├── login.php
├── dashboard.php       protected
└── logout.php          POST only

The service

AuthService.php
PHP
<?php

declare(strict_types=1);

final class AuthService
{
    private const DUMMY_HASH = '$2y$10$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG';

    public function __construct(private PDO $pdo, private int $maxFailures = 5, private int $windowSeconds = 900) {}

    /** @return array<string, string> field => error */
    public function validateRegistration(array $input): array
    {
        $errors = [];

        $name = trim((string) ($input["name"] ?? ""));
        $email = trim((string) ($input["email"] ?? ""));
        $password = (string) ($input["password"] ?? "");

        if ($name === "" || mb_strlen($name) > 100) {
            $errors["name"] = "Please enter your name.";
        }

        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false || mb_strlen($email) > 190) {
            $errors["email"] = "Please enter a valid email address.";
        }

        if (mb_strlen($password) < 10) {
            $errors["password"] = "Use at least 10 characters.";
        }

        if ($password !== (string) ($input["password_confirm"] ?? "")) {
            $errors["password_confirm"] = "The passwords do not match.";
        }

        return $errors;
    }

    /** @throws DomainException when the email is already used */
    public function register(string $name, string $email, string $password): int
    {
        try {
            $this->pdo->prepare("INSERT INTO users (name, email, password_hash, created_at) VALUES (:name, :email, :hash, :created)")
                ->execute([
                    "name" => trim($name),
                    "email" => strtolower(trim($email)),
                    "hash" => password_hash($password, PASSWORD_DEFAULT),
                    "created" => date("Y-m-d H:i:s"),
                ]);
        } catch (PDOException $e) {
            if ($e->getCode() === "23000") {
                throw new DomainException("That email address cannot be used.");
            }

            throw $e;
        }

        return (int) $this->pdo->lastInsertId();
    }

    /** @return array{id: int, name: string, role: string}|null */
    public function attempt(string $email, string $password, array &$failures = []): ?array
    {
        $now = time();
        $failures = array_values(array_filter($failures, fn(int $t) => $now - $t < $this->windowSeconds));

        if (count($failures) >= $this->maxFailures) {
            throw new RuntimeException("Too many attempts. Please wait a few minutes.");
        }

        $stmt = $this->pdo->prepare("SELECT id, name, role, password_hash FROM users WHERE email = :email");
        $stmt->execute(["email" => strtolower(trim($email))]);
        $user = $stmt->fetch();

        $ok = password_verify($password, $user["password_hash"] ?? self::DUMMY_HASH);

        if ($user === false || !$ok) {
            $failures[] = $now;

            return null;
        }

        if (password_needs_rehash($user["password_hash"], PASSWORD_DEFAULT)) {
            $this->pdo->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
                ->execute([password_hash($password, PASSWORD_DEFAULT), $user["id"]]);
        }

        return ["id" => (int) $user["id"], "name" => $user["name"], "role" => $user["role"]];
    }

    public function find(int $id): ?array
    {
        $stmt = $this->pdo->prepare("SELECT id, name, email, role FROM users WHERE id = :id");
        $stmt->execute(["id" => $id]);

        return $stmt->fetch() ?: null;
    }
}

Two details: failures are counted in a plain array that the caller keeps (in the page it will live in the session, and a stronger system would also store them in the database by IP address), and attempt() runs password_verify() even for unknown emails, so timing does not reveal which addresses exist.

Test every path

index.php
PHP
<?php
require __DIR__ . "/AuthService.php";

$auth = new AuthService(require __DIR__ . "/db.php", maxFailures: 3);

print_r($auth->validateRegistration(["name" => "", "email" => "x", "password" => "short", "password_confirm" => "different"]));

$id = $auth->register("Dev", "Dev@Example.com", "a long passphrase here");
echo "registered #$id\n";

try {
    $auth->register("Dev again", "dev@example.com", "another passphrase");
} catch (DomainException $e) {
    echo $e->getMessage() . "\n";
}

$failures = [];

var_dump($auth->attempt("dev@example.com", "wrong password", $failures));
var_dump($auth->attempt("nobody@example.com", "whatever", $failures));

$user = $auth->attempt("DEV@example.com ", "a long passphrase here", $failures);
echo $user["name"] . " logged in as " . $user["role"] . "\n";
echo count($failures) . " failures recorded\n";

try {
    $auth->attempt("dev@example.com", "guess", $failures);
    $auth->attempt("dev@example.com", "guess", $failures);
    $auth->attempt("dev@example.com", "guess", $failures);
} catch (RuntimeException $e) {
    echo $e->getMessage();
}
Output
OUTPUT
Array
(
    [name] => Please enter your name.
    [email] => Please enter a valid email address.
    [password] => Use at least 10 characters.
    [password_confirm] => The passwords do not match.
)
registered #4
That email address cannot be used.
NULL
NULL
Dev logged in as user
2 failures recorded
Too many attempts. Please wait a few minutes.

The whole security logic runs without a browser. (In the demo database, three users exist already, so the new user gets id 4.)

The helper for protected pages

functions.php
PHP
<?php
function requireLogin(AuthService $auth): array
{
    $id = $_SESSION["user_id"] ?? null;
    $user = is_int($id) ? $auth->find($id) : null;

    if ($user === null) {
        header("Location: login.php");
        exit;
    }

    return $user;
}

The pages

login.php
PHP
<?php
declare(strict_types=1);

session_set_cookie_params(["httponly" => true, "samesite" => "Lax", "secure" => !empty($_SERVER["HTTPS"])]);
session_start();

require __DIR__ . "/functions.php";
require __DIR__ . "/AuthService.php";

$auth = new AuthService(require __DIR__ . "/db.php");
$error = null;
$email = "";

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    csrfCheck();

    $email = trim((string) ($_POST["email"] ?? ""));
    $failures = $_SESSION["login_failures"] ?? [];

    try {
        $user = $auth->attempt($email, (string) ($_POST["password"] ?? ""), $failures);
    } catch (RuntimeException $e) {
        $user = null;
        $error = $e->getMessage();
    }

    $_SESSION["login_failures"] = $failures;

    if ($user !== null) {
        session_regenerate_id(true);            // prevents session fixation
        $_SESSION["user_id"] = $user["id"];
        unset($_SESSION["login_failures"]);

        header("Location: dashboard.php");
        exit;
    }

    $error ??= "Email or password is incorrect.";
}
?>
<h1>Log in</h1>
<?php if ($error): ?><p role="alert"><?= h($error) ?></p><?php endif; ?>
<form method="post">
  <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
  <p><label for="email">Email</label><br><input id="email" type="email" name="email" value="<?= h($email) ?>" autocomplete="username" required></p>
  <p><label for="password">Password</label><br><input id="password" type="password" name="password" autocomplete="current-password" required></p>
  <button type="submit">Log in</button>
</form>
<p><a href="register.php">Create an account</a></p>
dashboard.php
PHP
<?php
declare(strict_types=1);

session_start();

require __DIR__ . "/functions.php";
require __DIR__ . "/AuthService.php";

$auth = new AuthService(require __DIR__ . "/db.php");
$user = requireLogin($auth);

header("Cache-Control: no-store");
?>
<h1>Welcome, <?= h($user["name"]) ?></h1>
<p>Your role: <?= h($user["role"]) ?></p>

<form method="post" action="logout.php">
  <input type="hidden" name="csrf_token" value="<?= h(csrfToken()) ?>">
  <button type="submit">Log out</button>
</form>

register.php follows Registration, and logout.php is the POST-only logout from Logout.

Security review of your own work

CheckDone by
Passwords hashedpassword_hash() in register()
Hashes upgradedpassword_needs_rehash() in attempt()
Generic error, equal timingDummy hash
Rate limitingFailure list in the session (add a database counter for real protection)
Session fixationsession_regenerate_id(true)
CSRFToken on every form
Protected pagesrequireLogin() loads the user on every request
Cacheno-store on private pages
CookiesHttpOnly, SameSite, Secure

Challenges

  1. Add email verification with a random token (Password Reset shows the token pattern).
  2. Add password reset.
  3. Add a “remember me” option (Remember Me).
  4. Store failed attempts per IP and per account in the database.
  5. Add two-factor authentication with a TOTP library.