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:
- Prepared Statements: We use
prepare()andexecute()with named placeholders (:email) to prevent SQL injection attacks. This is non-negotiable for professional development. - Password Hashing: We never store passwords in plain text.
password_hash()creates a strong, one-way hash of the user's password. ThePASSWORD_DEFAULTconstant 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.
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.

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
$_SESSIONsuperglobal, 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$_SESSIONsuperglobal 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).