Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 64

Building User Authentication

Recommended resources

Hello! Welcome back to the capstone module.

In our last lesson, we laid a professional foundation for our project by setting up a modern directory structure using the MVC pattern, Composer, and PSR-4 autoloading. We have a clean slate, with our application logic separated from our public-facing files, and a powerful autoloader ready to load our classes on demand.

Today, we'll build the first major feature on top of that foundation. The learning outcome for this lesson is to implement a complete user authentication flow (register, login, logout). This is a cornerstone of almost every web application. We will create the necessary Models, Views, and Controllers to handle user registration with secure password hashing, manage user sessions for login, and provide a clean logout mechanism.

By the end of this lesson, you will have a functional and secure authentication system, bringing us a big step closer to building a complete web application.

1. Database and User Model Setup

Before a user can register, we need a place to store their information. This means setting up a database table and a Model to interact with it.

Step 1: Create the users Table

First, connect to your MySQL database using your preferred tool (like phpMyAdmin) and create a new database for this project if you haven't already. Then, run the following SQL query to create the users table:

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

This table includes a unique constraint on the email column to prevent duplicate accounts.

Step 2: Create a Database Connection Class

Our application needs a centralized way to connect to the database. We'll create a simple PDO connection wrapper in our app/Core directory.

Create the file app/Core/Database.php:

<?php

namespace App\Core;

use PDO;
use PDOException;

class Database
{
    private static ?PDO $instance = null;

    public static function getInstance(): PDO
    {
        if (self::$instance === null) {
            // --- IMPORTANT ---
            // Replace these with your actual database credentials
            $host = '127.0.0.1';
            $db   = 'your_database_name'; // The DB you created in Step 1
            $user = 'root';
            $pass = ''; // Your DB password, often empty in local XAMPP
            $charset = 'utf8mb4';
            // ---

            $dsn = "mysql:host=$host;dbname=$db;charset=$charset";
            $options = [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ];

            try {
                self::$instance = new PDO($dsn, $user, $pass, $options);
            } catch (PDOException $e) {
                // In a real application, you would log this error, not display it
                throw new PDOException($e->getMessage(), (int)$e->getCode());
            }
        }
        return self::$instance;
    }
}

This class uses the Singleton pattern to ensure we only ever have one database connection object. Remember to replace the placeholder credentials with your own.

Step 3: Create the User Model

Now, let's create the User model. This class will encapsulate all database logic related to users, such as creating a new user and finding an existing one.

Create the file app/Models/User.php:

<?php

namespace App\Models;

use App\Core\Database;
use PDO;

class User
{
    private PDO $db;

    public function __construct()
    {
        $this->db = Database::getInstance();
    }

    /**
     * Find a user by their email address.
     * @param string $email
     * @return array|false
     */
    public function findByEmail(string $email): array|false
    {
        $stmt = $this->db->prepare("SELECT * FROM users WHERE email = :email");
        $stmt->execute(['email' => $email]);
        return $stmt->fetch();
    }

    /**
     * Create a new user.
     * @param string $name
     * @param string $email
     * @param string $password
     * @return int|false The new user's ID or false on failure.
     */
    public function register(string $name, string $email, string $password): int|false
    {
        // Hash the password for security
        $passwordHash = password_hash($password, PASSWORD_DEFAULT);

        $stmt = $this->db->prepare(
            "INSERT INTO users (name, email, password) VALUES (:name, :email, :password)"
        );
        
        $success = $stmt->execute([
            'name' => $name,
            'email' => $email,
            'password' => $passwordHash
        ]);

        if ($success) {
            return (int)$this->db->lastInsertId();
        }

        return false;
    }
}

Two key things are happening here:

  1. Prepared Statements: We use prepare() and execute() with named placeholders (:email) to prevent SQL injection attacks. This is non-negotiable for professional development.
  2. Password Hashing: We never store passwords in plain text. password_hash() creates a strong, one-way hash of the user's password. The PASSWORD_DEFAULT constant ensures PHP will always use the best currently available hashing algorithm.

For a deeper look at secure password handling, this resource provides excellent code examples and explanations.

PHP Security Patterns

The article 'PHP Security Patterns' provides robust examples for handling passwords securely. The 'Secure Password Handling' section is particularly relevant here.

Review the PasswordManager class in the 'Secure Password Handling' section. Notice its use of password_hash(), password_verify(), and even password_needs_rehash(). Our User model implements a simpler version of this, but it's built on the same core principles.


2. The Registration Flow

With our model in place, we can build the user-facing registration feature. This involves a Controller to handle the request and a View to display the form.

User Login & Registration System with PHP
A visual example of the registration and login forms we are about to build.

Step 1: Create the AuthController and Routing

Create a new controller at app/Controllers/AuthController.php. For now, it will be empty.

<?php

namespace App\Controllers;

// We will add methods here soon
class AuthController
{
    //
}

Next, let's add some simple routing logic to our front controller. Open public/index.php and replace its content with this:

<?php

// 1. Start the session
session_start();

// 2. Load the Composer autoloader
require_once __DIR__ . '/../vendor/autoload.php';

// 3. Simple router
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// For simplicity, we are not using a dedicated router library yet.
// We will match the URI to a controller and method.
switch ($uri) {
    case '/':
        echo 'Welcome to the home page!';
        break;
    
    // We will add more routes here
    
    default:
        http_response_code(404);
        echo '404 - Page Not Found';
        break;
}

Note: We added session_start() at the very top. This function must be called on every page that needs access to session data, before any output is sent to the browser.

Step 2: Create the Registration View

Create the file views/register.php and add the following HTML form:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Register</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
</head>
<body>
    <h1>Register</h1>
    <form action="/register" method="post">
        <div>
            <label for="name">Name</label>
            <input type="text" id="name" name="name" required>
        </div>
        <div>
            <label for="email">Email</label>
            <input type="email" id="email" name="email" required>
        </div>
        <div>
            <label for="password">Password</label>
            <input type="password" id="password" name="password" required>
        </div>
        <button type="submit">Register</button>
    </form>
</body>
</html>

The form's action is set to /register and method is post. This is how we'll differentiate between a user asking for the form (GET request) and submitting it (POST request).

Step 3: Implement the Controller Logic for Registration

Now, let's add the logic to app/Controllers/AuthController.php and the corresponding routes to public/index.php.

In AuthController.php:

<?php

namespace App\Controllers;

use App\Models\User;

class AuthController
{
    public function showRegisterForm()
    {
        // Simply load the view
        require_once __DIR__ . '/../../views/register.php';
    }

    public function register()
    {
        // 1. Get user data from POST request
        $name = $_POST['name'] ?? '';
        $email = $_POST['email'] ?? '';
        $password = $_POST['password'] ?? '';

        // 2. Basic validation (in a real app, this would be more robust)
        if (empty($name) || empty($email) || empty($password)) {
            // Handle error - for now, just die
            die('Please fill all required fields.');
        }

        // 3. Check if user already exists
        $userModel = new User();
        if ($userModel->findByEmail($email)) {
            die('Email is already taken.');
        }
        
        // 4. Register the user
        $userId = $userModel->register($name, $email, $password);

        if ($userId) {
            // Redirect to login page on success
            header('Location: /login');
            exit;
        } else {
            die('Registration failed.');
        }
    }
}

In public/index.php, add the routes inside the switch statement:

// ... inside the switch statement
    case '/register':
        $authController = new App\Controllers\AuthController();
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $authController->register();
        } else {
            $authController->showRegisterForm();
        }
        break;
// ...

Now, navigate to http://localhost:8000/register. You should see the form. Fill it out and submit. If successful, you'll be redirected, and a new record will appear in your users table with a hashed password.


3. The Login & Logout Flow

Now that we can register users, let's implement the login and logout functionality.

Step 1: Create the Login View

Create views/login.php:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Login</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css">
</head>
<body>
    <h1>Login</h1>
    <form action="/login" method="post">
        <div>
            <label for="email">Email</label>
            <input type="email" id="email" name="email" required>
        </div>
        <div>
            <label for="password">Password</label>
            <input type="password" id="password" name="password" required>
        </div>
        <button type="submit">Login</button>
    </form>
</body>
</html>

Step 2: Implement Controller Logic for Login and Logout

Add the following methods to app/Controllers/AuthController.php:

// ... inside AuthController class

public function showLoginForm()
{
    require_once __DIR__ . '/../../views/login.php';
}

public function login()
{
    $email = $_POST['email'] ?? '';
    $password = $_POST['password'] ?? '';

    if (empty($email) || empty($password)) {
        die('Please enter email and password.');
    }

    $userModel = new User();
    $user = $userModel->findByEmail($email);

    // Verify user exists and password is correct
    if ($user && password_verify($password, $user['password'])) {
        // Password is correct. Log the user in.
        
        // Regenerate session ID to prevent session fixation attacks
        session_regenerate_id(true);

        // Store user ID in session
        $_SESSION['user_id'] = $user['id'];
        $_SESSION['user_name'] = $user['name'];

        // Redirect to the home page
        header('Location: /');
        exit;
    } else {
        // Handle login failure
        die('Invalid email or password.');
    }
}

public function logout()
{
    // Unset all of the session variables
    $_SESSION = [];

    // Destroy the session
    session_destroy();

    // Redirect to home page
    header('Location: /');
    exit;
}

The login() method is critical:

  • It finds the user by email.
  • It uses password_verify() to securely compare the submitted password against the stored hash. This function is designed to be safe from timing attacks.
  • Upon success, it calls session_regenerate_id(true)—a crucial security step to prevent session fixation.
  • It stores the user's ID in the $_SESSION superglobal, marking them as authenticated.

This video from The Codeholic provides a great walkthrough of implementing a similar login flow within a full MVC framework.

Implement login - Part 5 | PHP MVC Framework from Scratch

In this video, 'Implement login - Part 5' from the 'PHP MVC Framework from Scratch' series, the instructor implements the complete login flow, from handling the form submission to verifying credentials and managing the session.

Watch from 2:37 to 9:42 to see how the controller handles the POST request, validates data, finds the user, and verifies the password. Then, watch from 17:18 to 20:30 to understand how the user's ID is stored in the session to maintain the login state.

Step 3: Add the Final Routes

Finally, update public/index.php with the routes for login and logout:

// ... inside the switch statement in public/index.php

    case '/login':
        $authController = new App\Controllers\AuthController();
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            $authController->login();
        } else {
            $authController->showLoginForm();
        }
        break;

    case '/logout':
        $authController = new App\Controllers\AuthController();
        $authController->logout();
        break;
// ...

4. Updating the User Interface

Our final step is to make the application aware of the user's login status. Let's modify the home page to greet logged-in users and provide appropriate navigation links.

Replace the case '/': block in public/index.php with the following:

// ... inside the switch statement in public/index.php

    case '/':
        echo '<h1>Home Page</h1>';
        if (isset($_SESSION['user_id'])) {
            // User is logged in
            echo 'Welcome, ' . htmlspecialchars($_SESSION['user_name']) . '!';
            echo '<br><a href="/logout">Logout</a>';
        } else {
            // User is a guest
            echo 'You are not logged in.';
            echo '<br><a href="/login">Login</a> | <a href="/register">Register</a>';
        }
        break;
// ...

Here, we check if $_SESSION['user_id'] is set. If it is, we display a welcome message and a logout link. Otherwise, we show links to the login and registration pages. Note the use of htmlspecialchars() when printing the user's name—this is a basic but essential step to prevent XSS attacks, a topic we'll cover in detail in the security module.


Conclusion

Excellent work! You have successfully implemented a complete and secure user authentication system from scratch, building upon the MVC structure from our previous lesson.

Key Takeaways:

  • Registration: We created a form, a controller method to process it, and a model method that securely hashes the password with password_hash() before saving it to the database.
  • Login: We built a login form and a controller action that finds the user and verifies their credentials using password_verify().
  • Session Management: Upon successful login, we use session_regenerate_id(true) for security and store the user's identity in the $_SESSION superglobal to persist their authenticated state.
  • Logout: We created a simple action that destroys the session, effectively logging the user out.
  • Access Control: The UI now dynamically changes based on the user's authentication status, demonstrating a simple form of access control.

Preview of the Next Lesson:

Now that users can register and log in, our next goal is to allow them to create, read, update, and delete content. In the next lesson, you will develop full CRUD (Create, Read, Update, Delete) functionality for a resource. We will build the models, views, and controllers necessary for a logged-in user to manage their own data (e.g., posts or notes).