Build a Task Management App

Build a full-stack PHP and MySQL task management app with auth and CRUD.

Introduction

30 Second Summary

Everyone has a to-do list. Whether it's sticky notes on a desk or an app on your phone, behind every task management tool is a database, a login system, and code that ties it all together.

In this project, you will build TaskFlow, a full-stack task management web app using PHP and MySQL. You'll create user registration, login, and a personal dashboard where each user can create, edit, complete, and delete their own tasks.

What You'll Build

A local web application with secure user authentication, a relational database, and full CRUD (Create, Read, Update, Delete) functionality for managing tasks.

By the end of this project, you'll have:

  • A user registration and login system with securely hashed passwords and session-based authentication.
  • A MySQL database with relational tables for users and tasks, built through phpMyAdmin.
  • A task dashboard where logged-in users can create, edit, mark complete, and delete their own tasks.
  • Secret Mission: Add task priority levels (Low, Medium, High) with color-coded badges and the ability to filter tasks by priority.

Are there any prerequisites?

You need a basic understanding of HTML and CSS. No prior PHP or MySQL experience is required.

Before We Start

To build a web application with PHP, you need a local server environment that can run PHP code and store data in a database.

In this step, you will install XAMPP, a free tool that bundles Apache (a web server), MariaDB (a MySQL-compatible database), and PHP into one package. You will also set up VS Code as your code editor and confirm everything works.

In this step, get ready to:
  • Install XAMPP 8.2.12 with Apache and MySQL.
  • Start Apache and MySQL services and verify they are running.
  • Create your project folder and verify PHP works in the browser.
Download and install XAMPP

XAMPP gives you a fully working PHP development environment without needing to install and configure Apache, MySQL, and PHP separately.

  • Check whether XAMPP is already installed on your computer by looking for a C:\xampp folder.

✔️ I have XAMPP 8.2 installed

You are ready to go. Skip ahead to the next substep where you start Apache and MySQL.

ⓧ I have an older version of XAMPP

Older versions may ship an outdated PHP that is incompatible with the code in this project. Uninstall your existing XAMPP first, then follow the fresh install instructions in the next tab.

  • Open Settings on your computer.
  • Select Apps, then Apps & features.
  • Find XAMPP in the list, click it, and select Uninstall.
  • Once the uninstall completes, follow the fresh install steps below.
  • Open your browser and navigate to the XAMPP download page:
  • https://www.apachefriends.org/download.html
  • Under the XAMPP for Windows section, find the row for 8.2.12 / PHP 8.2.12 and click Download (64 bit).
  • Run the downloaded installer.
  • When prompted for the installation directory, keep the default C:\xampp.
  • On the component selection screen, make sure Apache and MySQL are checked.
  • Click through the remaining prompts and let the installer finish.

ⓧ I don't have XAMPP

  • Open your browser and navigate to the XAMPP download page:
  • https://www.apachefriends.org/download.html
  • Under the XAMPP for Windows section, find the row for 8.2.12 / PHP 8.2.12 and click Download (64 bit).
  • Run the downloaded installer.
  • When prompted for the installation directory, keep the default C:\xampp.
  • On the component selection screen, make sure Apache and MySQL are checked.
  • Click through the remaining prompts and let the installer finish.

What is XAMPP?

XAMPP stands for Cross-platform, Apache, MariaDB, PHP, and Perl. It bundles a web server (Apache), a database (MariaDB, which is MySQL-compatible), and PHP into a single installer.

This means you get everything you need to run a PHP web application locally without configuring each piece separately.

Why does XAMPP say MariaDB instead of MySQL?

XAMPP ships MariaDB instead of MySQL. MariaDB is a drop-in replacement for MySQL created by MySQL's original developers. It uses the same commands, the same SQL syntax, and the same connection strings. For this project, the two are interchangeable.

Start Apache and MySQL services
  • Open the XAMPP Control Panel. You can find it by pressing the Windows key and typing XAMPP, then pressing Enter.
  • In the XAMPP Control Panel, click Start next to Apache.
  • Click Start next to MySQL.
  • Both services should now show a green Running status in the Control Panel.
  • Open your browser and navigate to http://localhost to confirm Apache is serving pages.
  • You should see the XAMPP welcome dashboard page.

Apache won't start?

The most common reason is another program using port 80. On Windows, the World Wide Web Publishing Service (IIS) often occupies this port.

  • Press the Windows key, type services.msc, and press Enter.
  • Find World Wide Web Publishing Service in the list, right-click it, and select Stop.
  • Return to the XAMPP Control Panel and click Start next to Apache again.

If you still have trouble, help me fix XAMPP Apache not starting on my Windows machine.

Create your project folder and verify PHP

XAMPP serves files from its htdocs folder. Any folder you create inside C:\xampp\htdocs becomes a website you can visit at http://localhost/folder-name/.

  • Open File Explorer and navigate to C:\xampp\htdocs.
  • Create a new folder called taskflow.

Now open this folder in VS Code so you can start writing code.

✔️ I have VS Code installed

  • Press the Windows key, type VS Code, and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to C:\xampp\htdocs\taskflow, select it, and click Select Folder.

ⓧ I don't have VS Code

  • Open your browser and go to https://code.visualstudio.com/.
  • Click the Download for Windows button and run the installer.
  • Accept the defaults and complete the installation.
  • Once installed, press the Windows key, type VS Code, and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to C:\xampp\htdocs\taskflow, select it, and click Select Folder.

Now create a test file to verify PHP is working through Apache.

  • In VS Code, hover over the TASKFLOW heading in the Explorer sidebar and click the New File icon.
  • Name the file index.php.
  • Add the following code to index.php:
<?php phpinfo(); ?>

What does this code do?

The phpinfo() function outputs a page showing your full PHP configuration. It confirms that Apache is correctly processing .php files through the PHP interpreter.

  • Save the file with Ctrl+S.
  • Open your browser and navigate to http://localhost/taskflow/.
  • You should see a purple/blue PHP information page showing your PHP version (8.2.12) and configuration details.

Seeing a blank page or file download?

If the browser shows the raw PHP code or tries to download the file, Apache is not processing PHP files.

  • Make sure Apache is running (green status in the XAMPP Control Panel).
  • Confirm your file is saved at C:\xampp\htdocs\taskflow\index.php and not inside a nested subfolder.
  • Check that the URL is exactly http://localhost/taskflow/ with no typos.

If you still see issues, help me troubleshoot PHP not loading in XAMPP.

✔️ Awesome, I've got everything!

Great. Double check you have saved index.php and can see the PHP info page at http://localhost/taskflow/.

ⓧ I'd like to double check the full code

Here is the complete index.php file you should have:

<?php phpinfo(); ?>

Your development environment is ready. Next up, you will create the database that stores users and tasks.

Create the Database and Tables

TaskFlow needs a place to store user accounts and tasks permanently. Without a database, any data would disappear the moment you close the browser.

In this step, you will use phpMyAdmin to create a MySQL database and define two tables: one for storing user accounts, and one for storing tasks.

In this step, get ready to:
  • Create the taskflow_db database in phpMyAdmin.
  • Build the users table for storing account information.
  • Build the tasks table for storing to-do items.
Open phpMyAdmin and create the database

phpMyAdmin is a web-based tool that comes bundled with XAMPP. It gives you a visual interface for managing your databases without writing commands in a terminal.

  • In your browser, navigate to http://localhost/phpmyadmin.
  • Click New in the left sidebar.
  • In the Database name field, type taskflow_db.
  • From the collation dropdown, select utf8mb4_general_ci.
  • Click Create.

What is utf8mb4_general_ci?

This is a character encoding setting. utf8mb4 supports the full range of Unicode characters, including emojis. general_ci means text comparisons are case-insensitive, so "John" and "john" would match in searches.

After clicking Create, you should see taskflow_db appear in the left sidebar. phpMyAdmin also opens the database automatically.

Don't see the database in the sidebar?

Make sure the MySQL service is running in the XAMPP Control Panel (it should show green). If you see a connection error, click Start next to MySQL in XAMPP and refresh the phpMyAdmin page.

Help me troubleshoot my phpMyAdmin connection.

Create the users table

The users table stores each registered account. It needs columns for a unique ID, username, email, a hashed password, and the signup date.

  • With taskflow_db selected in the left sidebar, click the SQL tab at the top of the page.
  • Paste the following SQL statement into the query box and click Go:
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

What does this SQL do?

id is an auto-incrementing number that uniquely identifies each user. The database assigns it automatically whenever a new user registers.

username and email are both marked UNIQUE, so no two users can share the same username or email address.

password is VARCHAR(255) because hashed passwords are long strings. You will never store plain-text passwords here.

You should see a green success message saying the query was executed successfully. The users table now appears in the left sidebar under taskflow_db.

Seeing a syntax error?

Make sure you copied the entire statement including the semicolon at the end. Check that all parentheses are matched and that there are no extra characters before CREATE.

Help me fix my CREATE TABLE SQL error.

Create the tasks table

The tasks table stores each to-do item. Every task belongs to a specific user through a foreign key relationship, which links each task back to the user who created it.

  • Click the SQL tab again (or clear the previous query from the box).
  • Paste the following SQL statement and click Go:
CREATE TABLE tasks (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    title VARCHAR(255) NOT NULL,
    description TEXT NULL,
    is_completed TINYINT(1) DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

What does this SQL do?

user_id connects each task to the user who owns it. The FOREIGN KEY constraint ensures you cannot create a task for a user that does not exist.

is_completed uses TINYINT(1) as a boolean flag. A value of 0 means pending, and 1 means complete. It defaults to 0 so every new task starts as pending.

description is TEXT NULL because task descriptions are optional. A user can add a task with just a title.

  • Confirm you see a green success message after running the query.
  • Click taskflow_db in the left sidebar. You should now see both users and tasks listed as tables.

Table creation failed?

If you see an error about the foreign key, make sure you created the users table first. The tasks table references users.id, so users must already exist.

If you accidentally ran the tasks query first, drop the failed table by running DROP TABLE IF EXISTS tasks; and then run the users query followed by the tasks query in order.

Help me fix my foreign key error when creating the tasks table.

Why a foreign key?

The foreign key between tasks.user_id and users.id creates a relationship between the two tables. This ensures data integrity. The database itself prevents orphan tasks that point to a non-existent user.

Your database is ready. You have two tables that will hold all of TaskFlow's data. Next up, you will connect PHP to this database and build the registration and login system.

Build the Database Connection and Authentication

Your database is ready with the users and tasks tables waiting for data. Now you need PHP code that can actually talk to that database and manage user accounts.

In this step, you will create a secure connection to your database, then build a registration page and a login page. By the end, users will be able to create accounts with hashed passwords and log in with session-based authentication.

In this step, get ready to:
  • Create a reusable database connection file using PDO.
  • Build a registration page that hashes passwords with bcrypt.
  • Build a login page with session-based authentication and a logout script.
Create the database connection file

Every PHP file that needs to read or write data will connect to your database. Instead of repeating connection code in every file, you create a single configuration file and include it wherever you need it.

  • In VS Code, right-click the taskflow folder in the Explorer sidebar.
  • Click New File and name it config.php.
  • Add the database connection code by pasting the following into config.php:
<?php
// Database connection settings
$host = 'localhost';
$dbname = 'taskflow_db';
$username = 'root';
$password = '';

// Create a PDO connection to MySQL
try {
    $pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $username, $password);
    // Throw exceptions on database errors
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    // Return results as associative arrays
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
    die("Connection failed: " . $e->getMessage());
}

What does this code do?

PDO (PHP Data Objects) is PHP's standard way to communicate with databases. It works with MySQL, PostgreSQL, SQLite, and more using the same syntax.

  • The connection string mysql:host=$host;dbname=$dbname;charset=utf8mb4 tells PDO which database to connect to and which character encoding to use.
  • ERRMODE_EXCEPTION makes PDO throw clear error messages instead of failing silently.
  • FETCH_ASSOC means query results come back as arrays with column names as keys (e.g. $row['username']).
  • The try/catch block handles connection failures gracefully instead of crashing with an ugly PHP error.
  • Save config.php.
  • Open your browser and navigate to http://localhost/taskflow/config.php. You should see a blank white page with no errors.

Seeing an error message?

If you see "Connection failed" followed by an error, check that MySQL is still running in the XAMPP Control Panel (the green "Running" status).

If you see a syntax error, make sure the opening <?php tag is on the very first line with no spaces or blank lines before it.

Help me debug this

✔️ Awesome, I've got everything!

Great. Make sure you have saved config.php before moving on.

ⓧ I'd like to double check the full code

<?php
$host = 'localhost';
$dbname = 'taskflow_db';
$username = 'root';
$password = '';

try {
    $pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $username, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
    die("Connection failed: " . $e->getMessage());
}
Create the registration page

The registration page needs two things: an HTML form for user input, and PHP logic to validate that input, hash the password, and save the new account to the database.

  • Create a new file called register.php in the taskflow folder.
  • Add the PHP processing logic at the top of register.php by pasting this code:
<?php
require_once 'config.php';

$error = '';
$success = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Get form data and trim whitespace
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';

    // Validate all fields are filled
    if (empty($username) || empty($email) || empty($password)) {
        $error = 'All fields are required.';
    } elseif (strlen($password) < 6) {
        $error = 'Password must be at least 6 characters.';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $error = 'Please enter a valid email address.';
    } else {
        // Check if username or email already exists
        $stmt = $pdo->prepare("SELECT id FROM users WHERE email = :email OR username = :username");
        $stmt->execute([':email' => $email, ':username' => $username]);

        if ($stmt->fetch()) {
            $error = 'Username or email already exists.';
        } else {
            // Hash the password and insert the new user
            $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
            $stmt = $pdo->prepare("INSERT INTO users (username, email, password) VALUES (:username, :email, :password)");
            $stmt->execute([
                ':username' => $username,
                ':email' => $email,
                ':password' => $hashedPassword
            ]);
            $success = 'Registration successful! You can now log in.';
        }
    }
}
?>

What does this code do?

This block runs only when the form is submitted (POST request). It performs three layers of validation before touching the database.

  • require_once 'config.php' loads your database connection so $pdo is available.
  • Input validation checks for empty fields, short passwords, and invalid email formats.
  • A prepared statement checks whether the username or email is already taken.
  • password_hash($password, PASSWORD_DEFAULT) hashes the password using bcrypt. The original password is never stored.
  • Named parameters like :username prevent SQL injection by separating data from the query structure.

What is password hashing?

Hashing converts a password into a scrambled string that cannot be reversed. If someone steals your database, they cannot recover the original passwords.

PHP's password_hash() with PASSWORD_DEFAULT uses the bcrypt algorithm. It automatically generates a unique salt for each password, so two users with the same password get different hashes.

  • Now add the HTML form directly below the closing ?> tag in the same file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Register - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="auth-container">
        <h1>TaskFlow</h1>
        <h2>Create Account</h2>
        <?php if ($error): ?>
            <div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
        <?php endif; ?>
        <?php if ($success): ?>
            <div class="alert alert-success"><?php echo htmlspecialchars($success); ?></div>
        <?php endif; ?>
        <form method="POST" action="">
            <div class="form-group">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" value="<?php echo htmlspecialchars($username ?? ''); ?>" required>
            </div>
            <div class="form-group">
                <label for="email">Email</label>
                <input type="email" id="email" name="email" value="<?php echo htmlspecialchars($email ?? ''); ?>" required>
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit" class="btn btn-primary">Register</button>
        </form>
        <p class="auth-link">Already have an account? <a href="login.php">Log in</a></p>
    </div>
</body>
</html>

What does this HTML do?

  • The <?php if ($error): ?> blocks conditionally display error or success messages only when they exist.
  • htmlspecialchars() prevents cross-site scripting (XSS) by escaping any HTML characters in user input.
  • The value attributes on inputs preserve what the user typed if validation fails, so they do not have to re-enter everything.
  • The form submits to itself (action="") so the PHP logic at the top processes the submission.
  • Save register.php.
  • Open your browser and navigate to http://localhost/taskflow/register.php. You should see a registration form with Username, Email, and Password fields.

Seeing a blank page or error?

If the page is blank, check that the ?> closing tag appears between the PHP logic and the HTML. The HTML must come after ?>, not inside the PHP block.

If you see a "failed to open stream" error, make sure config.php is in the same folder as register.php.

Help me debug my registration page

Do not try to register yet. The form will display correctly, but the styling comes in a later step.

✔️ Awesome, I've got everything!

Great. Make sure register.php is saved and loads in your browser without errors.

ⓧ I'd like to double check the full code

<?php
require_once 'config.php';

$error = '';
$success = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';

    if (empty($username) || empty($email) || empty($password)) {
        $error = 'All fields are required.';
    } elseif (strlen($password) < 6) {
        $error = 'Password must be at least 6 characters.';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $error = 'Please enter a valid email address.';
    } else {
        $stmt = $pdo->prepare("SELECT id FROM users WHERE email = :email OR username = :username");
        $stmt->execute([':email' => $email, ':username' => $username]);

        if ($stmt->fetch()) {
            $error = 'Username or email already exists.';
        } else {
            $hashedPassword = password_hash($password, PASSWORD_DEFAULT);
            $stmt = $pdo->prepare("INSERT INTO users (username, email, password) VALUES (:username, :email, :password)");
            $stmt->execute([
                ':username' => $username,
                ':email' => $email,
                ':password' => $hashedPassword
            ]);
            $success = 'Registration successful! You can now log in.';
        }
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Register - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="auth-container">
        <h1>TaskFlow</h1>
        <h2>Create Account</h2>
        <?php if ($error): ?>
            <div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
        <?php endif; ?>
        <?php if ($success): ?>
            <div class="alert alert-success"><?php echo htmlspecialchars($success); ?></div>
        <?php endif; ?>
        <form method="POST" action="">
            <div class="form-group">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" value="<?php echo htmlspecialchars($username ?? ''); ?>" required>
            </div>
            <div class="form-group">
                <label for="email">Email</label>
                <input type="email" id="email" name="email" value="<?php echo htmlspecialchars($email ?? ''); ?>" required>
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit" class="btn btn-primary">Register</button>
        </form>
        <p class="auth-link">Already have an account? <a href="login.php">Log in</a></p>
    </div>
</body>
</html>
Build the login and logout system

Registration creates accounts. Login lets users prove who they are and access their data. PHP sessions keep users logged in across page loads by storing their identity on the server.

  • Create a new file called login.php in the taskflow folder.
  • Add the PHP authentication logic at the top of login.php by pasting this code:
<?php
session_start();
require_once 'config.php';

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';

    if (empty($email) || empty($password)) {
        $error = 'All fields are required.';
    } else {
        // Look up the user by email
        $stmt = $pdo->prepare("SELECT id, username, password FROM users WHERE email = :email");
        $stmt->execute([':email' => $email]);
        $user = $stmt->fetch();

        // Verify the password against the stored hash
        if ($user && password_verify($password, $user['password'])) {
            // Regenerate session ID to prevent session fixation attacks
            session_regenerate_id(true);
            $_SESSION['user_id'] = $user['id'];
            $_SESSION['username'] = $user['username'];
            header('Location: dashboard.php');
            exit;
        } else {
            $error = 'Invalid email or password.';
        }
    }
}
?>

What does this code do?

This logic verifies user credentials and creates a secure session.

  • session_start() enables PHP sessions so you can store data between page loads.
  • password_verify() compares the plain-text password against the bcrypt hash stored in the database. It never decrypts the hash.
  • session_regenerate_id(true) creates a new session ID after login. This prevents session fixation attacks where an attacker tricks you into using their session.
  • $_SESSION['user_id'] stores the logged-in user's ID on the server. Other pages check this to know who is logged in.
  • header('Location: dashboard.php') redirects the user to the dashboard after successful login.
  • Add the HTML login form directly below the closing ?> tag in login.php:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="auth-container">
        <h1>TaskFlow</h1>
        <h2>Log In</h2>
        <?php if ($error): ?>
            <div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
        <?php endif; ?>
        <form method="POST" action="">
            <div class="form-group">
                <label for="email">Email</label>
                <input type="email" id="email" name="email" value="<?php echo htmlspecialchars($email ?? ''); ?>" required>
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit" class="btn btn-primary">Log In</button>
        </form>
        <p class="auth-link">Don't have an account? <a href="register.php">Register</a></p>
    </div>
</body>
</html>

What's different from the registration form?

The login form only asks for email and password. It does not need a username field because the user already registered that.

The link at the bottom points to register.php for new users, while the registration page links back to login.php. This creates a natural flow between the two pages.

  • Save login.php.
  • Open your browser and navigate to http://localhost/taskflow/login.php. You should see a login form with Email and Password fields.

Page not loading correctly?

Make sure session_start() is the very first thing after <?php in login.php. If any output (including blank lines or spaces) comes before session_start(), PHP will throw a headers error.

Help me debug my login page

✔️ Awesome, I've got everything!

Great. Make sure login.php is saved and the form displays in your browser.

ⓧ I'd like to double check the full code

<?php
session_start();
require_once 'config.php';

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';

    if (empty($email) || empty($password)) {
        $error = 'All fields are required.';
    } else {
        $stmt = $pdo->prepare("SELECT id, username, password FROM users WHERE email = :email");
        $stmt->execute([':email' => $email]);
        $user = $stmt->fetch();

        if ($user && password_verify($password, $user['password'])) {
            session_regenerate_id(true);
            $_SESSION['user_id'] = $user['id'];
            $_SESSION['username'] = $user['username'];
            header('Location: dashboard.php');
            exit;
        } else {
            $error = 'Invalid email or password.';
        }
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Login - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="auth-container">
        <h1>TaskFlow</h1>
        <h2>Log In</h2>
        <?php if ($error): ?>
            <div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
        <?php endif; ?>
        <form method="POST" action="">
            <div class="form-group">
                <label for="email">Email</label>
                <input type="email" id="email" name="email" value="<?php echo htmlspecialchars($email ?? ''); ?>" required>
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit" class="btn btn-primary">Log In</button>
        </form>
        <p class="auth-link">Don't have an account? <a href="register.php">Register</a></p>
    </div>
</body>
</html>

Now you need a way for users to log out. This clears their session data so they are no longer authenticated.

  • Create a new file called logout.php in the taskflow folder.
  • Add the logout logic by pasting this code into logout.php:
<?php
// Resume the existing session so we can destroy it
session_start();
// Clear all session variables
session_unset();
// Destroy the session entirely
session_destroy();
// Send the user back to the login page
header('Location: login.php');
exit;

What does this code do?

  • session_start() resumes the current session so PHP can access it.
  • session_unset() removes all variables stored in the session (like user_id and username).
  • session_destroy() deletes the session file on the server. The user is fully logged out.
  • The redirect sends the user back to the login page automatically.
  • Save logout.php.
  • You will not test logout yet because there is no dashboard to log into. You will use it in the next step.

✔️ Awesome, I've got everything!

Great. Confirm you have saved logout.php in your taskflow folder.

ⓧ I'd like to double check the full code

<?php
session_start();
session_unset();
session_destroy();
header('Location: login.php');
exit;

Your authentication system is complete. You have a database connection, user registration with hashed passwords, session-based login, and a logout script. Next up, you will build the task dashboard that logged-in users see after they sign in.

Build the Task Dashboard

Your authentication system is ready. Users can register and log in, but after a successful login they get redirected to dashboard.php which does not exist yet.

The dashboard is the heart of TaskFlow. It is where users see all their tasks, check what is pending, and take action on items. In this step, you will build the dashboard page that fetches the logged-in user's tasks from the database and displays them with action buttons.

In this step, get ready to:
  • Protect the dashboard with session-based authentication.
  • Fetch and display all tasks for the logged-in user.
  • Test the full login-to-dashboard-to-logout flow.
Create the dashboard session and query logic

Every page that shows user-specific data needs to verify the user is actually logged in. Without this check, anyone could type the URL and see another person's tasks.

  • In VS Code, create a new file called dashboard.php inside your C:\xampp\htdocs\taskflow folder.
  • Add the following PHP logic at the top of the file:
<?php
session_start();
require_once 'config.php';

// Redirect to login if the user is not authenticated
if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

// Retrieve the logged-in user's ID and username from the session
$userId = $_SESSION['user_id'];
$username = $_SESSION['username'];

// Fetch all tasks belonging to this user, newest first
$stmt = $pdo->prepare("SELECT * FROM tasks WHERE user_id = :user_id ORDER BY created_at DESC");
$stmt->execute([':user_id' => $userId]);
$tasks = $stmt->fetchAll();
?>

What does this code do?

This PHP block runs before any HTML is sent to the browser. It handles three jobs:

  • session_start() resumes the session so you can read $_SESSION['user_id'].
  • The if (!isset(...)) check acts as a guard. If no user is logged in, it redirects them to the login page immediately.
  • The PDO prepared statement fetches only the tasks that belong to this specific user, preventing them from seeing other users' data.

Why use a prepared statement here?

The :user_id placeholder ensures the user's ID is safely bound to the query. Even though this value comes from the session (not direct user input), using prepared statements everywhere builds a consistent habit that prevents SQL injection.

  • Save dashboard.php.
Display tasks with action buttons

The PHP logic fetches the data, but you still need HTML to display it. This template renders each task with its title, description, creation date, and a set of action buttons the user can click.

  • In dashboard.php, add the following HTML directly below the closing ?> tag:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Dashboard - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="dashboard-container">
        <header class="dashboard-header">
            <h1>TaskFlow</h1>
            <nav>
                <span>Welcome, <?php echo htmlspecialchars($username); ?></span>
                <a href="logout.php" class="btn btn-logout">Log Out</a>
            </nav>
        </header>
        <main>
            <div class="tasks-header">
                <h2>My Tasks</h2>
                <a href="add_task.php" class="btn btn-primary">+ New Task</a>
            </div>
            <?php if (empty($tasks)): ?>
                <p class="no-tasks">No tasks yet. Create your first task!</p>
            <?php else: ?>
                <div class="task-list">
                    <?php foreach ($tasks as $task): ?>
                        <div class="task-item <?php echo $task['is_completed'] ? 'completed' : ''; ?>">
                            <div class="task-content">
                                <h3><?php echo htmlspecialchars($task['title']); ?></h3>
                                <?php if ($task['description']): ?>
                                    <p><?php echo htmlspecialchars($task['description']); ?></p>
                                <?php endif; ?>
                                <small>Created: <?php echo date('M j, Y', strtotime($task['created_at'])); ?></small>
                            </div>
                            <div class="task-actions">
                                <a href="toggle_task.php?id=<?php echo $task['id']; ?>" class="btn btn-complete">
                                    <?php echo $task['is_completed'] ? 'Undo' : 'Complete'; ?>
                                </a>
                                <a href="edit_task.php?id=<?php echo $task['id']; ?>" class="btn btn-edit">Edit</a>
                                <a href="delete_task.php?id=<?php echo $task['id']; ?>" class="btn btn-delete" onclick="return confirm('Are you sure you want to delete this task?');">Delete</a>
                            </div>
                        </div>
                    <?php endforeach; ?>
                </div>
            <?php endif; ?>
        </main>
    </div>
</body>
</html>

What does this template do?

  • The header shows the app name, a welcome message with the user's name, and a Log Out link that points to logout.php.
  • If the $tasks array is empty, a friendly message appears instead of a blank page.
  • Each task gets a completed CSS class when is_completed is true. This lets you style finished tasks differently later.
  • The action buttons (Complete/Undo, Edit, Delete) link to pages you will build in the next step. The Delete button includes a JavaScript confirmation dialog.
  • Save dashboard.php.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete dashboard.php file:

<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$userId = $_SESSION['user_id'];
$username = $_SESSION['username'];

$stmt = $pdo->prepare("SELECT * FROM tasks WHERE user_id = :user_id ORDER BY created_at DESC");
$stmt->execute([':user_id' => $userId]);
$tasks = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Dashboard - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="dashboard-container">
        <header class="dashboard-header">
            <h1>TaskFlow</h1>
            <nav>
                <span>Welcome, <?php echo htmlspecialchars($username); ?></span>
                <a href="logout.php" class="btn btn-logout">Log Out</a>
            </nav>
        </header>
        <main>
            <div class="tasks-header">
                <h2>My Tasks</h2>
                <a href="add_task.php" class="btn btn-primary">+ New Task</a>
            </div>
            <?php if (empty($tasks)): ?>
                <p class="no-tasks">No tasks yet. Create your first task!</p>
            <?php else: ?>
                <div class="task-list">
                    <?php foreach ($tasks as $task): ?>
                        <div class="task-item <?php echo $task['is_completed'] ? 'completed' : ''; ?>">
                            <div class="task-content">
                                <h3><?php echo htmlspecialchars($task['title']); ?></h3>
                                <?php if ($task['description']): ?>
                                    <p><?php echo htmlspecialchars($task['description']); ?></p>
                                <?php endif; ?>
                                <small>Created: <?php echo date('M j, Y', strtotime($task['created_at'])); ?></small>
                            </div>
                            <div class="task-actions">
                                <a href="toggle_task.php?id=<?php echo $task['id']; ?>" class="btn btn-complete">
                                    <?php echo $task['is_completed'] ? 'Undo' : 'Complete'; ?>
                                </a>
                                <a href="edit_task.php?id=<?php echo $task['id']; ?>" class="btn btn-edit">Edit</a>
                                <a href="delete_task.php?id=<?php echo $task['id']; ?>" class="btn btn-delete" onclick="return confirm('Are you sure you want to delete this task?');">Delete</a>
                            </div>
                        </div>
                    <?php endforeach; ?>
                </div>
            <?php endif; ?>
        </main>
    </div>
</body>
</html>
Test the login-to-dashboard flow

The logout.php file you created earlier destroys the session and redirects back to the login page. With the dashboard in place, you can now test the complete authentication cycle.

  • Open your browser and navigate to http://localhost/taskflow/dashboard.php.
  • You should be redirected to the login page because you have no active session.
  • Log in with the account you registered earlier.
  • You should see the TaskFlow dashboard with the heading My Tasks and the message "No tasks yet. Create your first task!" since you have not added any tasks.
  • Click Log Out in the top right corner.
  • You should be redirected back to the login page.

Redirected to login but you just logged in?

  • Make sure session_start() is the very first line in dashboard.php (after <?php). No whitespace or HTML can appear before it.
  • Check that login.php stores $_SESSION['user_id'] after a successful login. Open it and confirm the line exists.
  • Verify Apache is still running in the XAMPP Control Panel. A stopped server returns connection errors, not redirects.

Why is my dashboard redirecting me to the login page?

Seeing a blank page or PHP error?

  • A blank page usually means a PHP syntax error. Check for missing semicolons or unclosed <?php ... ?> tags.
  • Make sure require_once 'config.php' uses the correct filename. A typo here causes a fatal error.

Help me debug a blank page on my PHP dashboard.

Your dashboard is live and protected behind authentication. Next up, you will build the pages that let users create, edit, and delete tasks from this dashboard.

Implement Create, Edit, and Delete Tasks

In the previous step, you built a dashboard that displays tasks and links to action pages. Right now, clicking those action buttons leads to pages that don't exist yet.

In this step, you'll build the four files that handle CRUD operations: creating tasks, editing them, toggling their completion status, and deleting them. Each file checks that the user owns the task before making any changes.

In this step, get ready to:
  • Create the add task form and database INSERT logic.
  • Create the edit task form with pre-filled data and UPDATE logic.
  • Create the toggle complete and delete task handlers.
Create the add task page

The add task page needs to do two things: show a form for entering a title and description, and insert the new task into the database when the form is submitted. It also needs to verify the user is logged in before doing anything.

  • In VS Code, create a new file called add_task.php inside your taskflow folder.
  • Paste the following code into add_task.php:
<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $title = trim($_POST['title'] ?? '');
    $description = trim($_POST['description'] ?? '');

    if (empty($title)) {
        $error = 'Task title is required.';
    } else {
        // Insert the new task linked to the logged-in user
        $stmt = $pdo->prepare("INSERT INTO tasks (user_id, title, description) VALUES (:user_id, :title, :description)");
        $stmt->execute([
            ':user_id' => $_SESSION['user_id'],
            ':title' => $title,
            ':description' => $description
        ]);
        header('Location: dashboard.php');
        exit;
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Add Task - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="form-container">
        <h2>Add New Task</h2>
        <?php if ($error): ?>
            <div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
        <?php endif; ?>
        <form method="POST" action="">
            <div class="form-group">
                <label for="title">Title</label>
                <input type="text" id="title" name="title" value="<?php echo htmlspecialchars($title ?? ''); ?>" required>
            </div>
            <div class="form-group">
                <label for="description">Description (optional)</label>
                <textarea id="description" name="description" rows="4"><?php echo htmlspecialchars($description ?? ''); ?></textarea>
            </div>
            <button type="submit" class="btn btn-primary">Add Task</button>
            <a href="dashboard.php" class="btn btn-secondary">Cancel</a>
        </form>
    </div>
</body>
</html>

What does this code do?

The PHP logic at the top handles two scenarios depending on the request method.

  • On a GET request (first page load), it simply displays the empty form.
  • On a POST request (form submission), it validates the title is not empty, then uses a PDO prepared statement to safely insert the task into the database.
  • The session check at the top redirects unauthenticated users to the login page. The :user_id parameter ties the new task to the logged-in user.
  • Save add_task.php.
  • In your browser, go to http://localhost/taskflow/dashboard.php and click + New Task.
  • You should see a form with Title and Description fields, plus Add Task and Cancel buttons.
  • Enter a title like Buy groceries and click Add Task. You should be redirected to the dashboard with your new task visible.

Page not loading or showing a blank screen?

  • Make sure you saved the file as add_task.php (not .html) inside C:\xampp\htdocs\taskflow.
  • Check that Apache is still running in XAMPP Control Panel.
  • If you see a database error, confirm that MySQL is running and config.php is in the same folder.

Help me debug my add_task.php page

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $title = trim($_POST['title'] ?? '');
    $description = trim($_POST['description'] ?? '');

    if (empty($title)) {
        $error = 'Task title is required.';
    } else {
        $stmt = $pdo->prepare("INSERT INTO tasks (user_id, title, description) VALUES (:user_id, :title, :description)");
        $stmt->execute([
            ':user_id' => $_SESSION['user_id'],
            ':title' => $title,
            ':description' => $description
        ]);
        header('Location: dashboard.php');
        exit;
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Add Task - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="form-container">
        <h2>Add New Task</h2>
        <?php if ($error): ?>
            <div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
        <?php endif; ?>
        <form method="POST" action="">
            <div class="form-group">
                <label for="title">Title</label>
                <input type="text" id="title" name="title" value="<?php echo htmlspecialchars($title ?? ''); ?>" required>
            </div>
            <div class="form-group">
                <label for="description">Description (optional)</label>
                <textarea id="description" name="description" rows="4"><?php echo htmlspecialchars($description ?? ''); ?></textarea>
            </div>
            <button type="submit" class="btn btn-primary">Add Task</button>
            <a href="dashboard.php" class="btn btn-secondary">Cancel</a>
        </form>
    </div>
</body>
</html>
Create the edit task page

The edit page is similar to the add page, but it needs to load the existing task data first. It also verifies the task belongs to the currently logged-in user before allowing any changes.

  • Create a new file called edit_task.php in your taskflow folder.
  • Paste the following code into edit_task.php:
<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$taskId = $_GET['id'] ?? null;

if (!$taskId) {
    header('Location: dashboard.php');
    exit;
}

// Fetch the task, ensuring it belongs to the logged-in user
$stmt = $pdo->prepare("SELECT * FROM tasks WHERE id = :id AND user_id = :user_id");
$stmt->execute([':id' => $taskId, ':user_id' => $_SESSION['user_id']]);
$task = $stmt->fetch();

if (!$task) {
    header('Location: dashboard.php');
    exit;
}

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $title = trim($_POST['title'] ?? '');
    $description = trim($_POST['description'] ?? '');

    if (empty($title)) {
        $error = 'Task title is required.';
    } else {
        // Update the task with new values
        $stmt = $pdo->prepare("UPDATE tasks SET title = :title, description = :description WHERE id = :id AND user_id = :user_id");
        $stmt->execute([
            ':title' => $title,
            ':description' => $description,
            ':id' => $taskId,
            ':user_id' => $_SESSION['user_id']
        ]);
        header('Location: dashboard.php');
        exit;
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Edit Task - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="form-container">
        <h2>Edit Task</h2>
        <?php if ($error): ?>
            <div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
        <?php endif; ?>
        <form method="POST" action="">
            <div class="form-group">
                <label for="title">Title</label>
                <input type="text" id="title" name="title" value="<?php echo htmlspecialchars($title ?? $task['title']); ?>" required>
            </div>
            <div class="form-group">
                <label for="description">Description (optional)</label>
                <textarea id="description" name="description" rows="4"><?php echo htmlspecialchars($description ?? $task['description']); ?></textarea>
            </div>
            <button type="submit" class="btn btn-primary">Save Changes</button>
            <a href="dashboard.php" class="btn btn-secondary">Cancel</a>
        </form>
    </div>
</body>
</html>

What does this code do?

  • The $_GET['id'] value comes from the URL query string (e.g. edit_task.php?id=3). This tells the page which task to load.
  • The SELECT query includes AND user_id = :user_id to verify the task belongs to the logged-in user. If it doesn't match, the page redirects to the dashboard.
  • The form's input fields use $task['title'] and $task['description'] as default values, so the learner sees the existing data pre-filled.
  • The UPDATE statement also checks user_id again for extra safety, preventing tampering with the task ID in the URL.
  • Save edit_task.php.
  • Return to your dashboard and click the Edit button on the task you created earlier.
  • You should see the edit form with your task's title and description already filled in.
  • Change the title to something different and click Save Changes. The dashboard should now show the updated title.

Edit page redirecting straight to dashboard?

  • Make sure you are logged in with the same account that created the task. The ownership check will fail if the user IDs don't match.
  • Check that the URL includes ?id= followed by a number. If id is missing, the page redirects immediately.

Help me debug my edit_task.php page

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$taskId = $_GET['id'] ?? null;

if (!$taskId) {
    header('Location: dashboard.php');
    exit;
}

$stmt = $pdo->prepare("SELECT * FROM tasks WHERE id = :id AND user_id = :user_id");
$stmt->execute([':id' => $taskId, ':user_id' => $_SESSION['user_id']]);
$task = $stmt->fetch();

if (!$task) {
    header('Location: dashboard.php');
    exit;
}

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $title = trim($_POST['title'] ?? '');
    $description = trim($_POST['description'] ?? '');

    if (empty($title)) {
        $error = 'Task title is required.';
    } else {
        $stmt = $pdo->prepare("UPDATE tasks SET title = :title, description = :description WHERE id = :id AND user_id = :user_id");
        $stmt->execute([
            ':title' => $title,
            ':description' => $description,
            ':id' => $taskId,
            ':user_id' => $_SESSION['user_id']
        ]);
        header('Location: dashboard.php');
        exit;
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Edit Task - TaskFlow</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="form-container">
        <h2>Edit Task</h2>
        <?php if ($error): ?>
            <div class="alert alert-error"><?php echo htmlspecialchars($error); ?></div>
        <?php endif; ?>
        <form method="POST" action="">
            <div class="form-group">
                <label for="title">Title</label>
                <input type="text" id="title" name="title" value="<?php echo htmlspecialchars($title ?? $task['title']); ?>" required>
            </div>
            <div class="form-group">
                <label for="description">Description (optional)</label>
                <textarea id="description" name="description" rows="4"><?php echo htmlspecialchars($description ?? $task['description']); ?></textarea>
            </div>
            <button type="submit" class="btn btn-primary">Save Changes</button>
            <a href="dashboard.php" class="btn btn-secondary">Cancel</a>
        </form>
    </div>
</body>
</html>
Create the toggle complete and delete handlers

Unlike the add and edit pages, the toggle and delete handlers have no HTML form. They perform a single database operation and immediately redirect back to the dashboard.

  • Create a new file called toggle_task.php in your taskflow folder.
  • Paste the following code into toggle_task.php:
<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$taskId = $_GET['id'] ?? null;

if ($taskId) {
    // Flip is_completed: 0 becomes 1, 1 becomes 0
    $stmt = $pdo->prepare("UPDATE tasks SET is_completed = NOT is_completed WHERE id = :id AND user_id = :user_id");
    $stmt->execute([':id' => $taskId, ':user_id' => $_SESSION['user_id']]);
}

header('Location: dashboard.php');
exit;

What does NOT is_completed do?

The SQL expression NOT is_completed flips the value. If it's 0 (pending), it becomes 1 (complete). If it's 1, it becomes 0. This single query handles both toggling directions without needing to read the current value first.

  • Save toggle_task.php.
  • Now create another new file called delete_task.php in the same folder.
  • Paste the following code into delete_task.php:
<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$taskId = $_GET['id'] ?? null;

if ($taskId) {
    // Delete the task only if it belongs to the logged-in user
    $stmt = $pdo->prepare("DELETE FROM tasks WHERE id = :id AND user_id = :user_id");
    $stmt->execute([':id' => $taskId, ':user_id' => $_SESSION['user_id']]);
}

header('Location: dashboard.php');
exit;

Why check user_id in the DELETE query?

Without the AND user_id = :user_id clause, a user could manually change the id in the URL to delete someone else's tasks. The ownership check makes this impossible.

  • Save delete_task.php.
  • Return to your dashboard and click the Complete button on your task. The page should reload with a strikethrough on the task title and the button text changing to Undo.
  • Click Undo to toggle it back to pending.
  • Click the Delete button. A confirmation dialog appears. Click OK and the task should disappear from the dashboard.

Toggle or delete not working?

  • Check that both files are saved with the .php extension in your taskflow folder.
  • Make sure the file names match exactly: toggle_task.php and delete_task.php. The dashboard links point to these exact file names.
  • If clicking Complete does nothing visible, check that your style.css has the .completed class styles. The toggle still works even without styling, but you won't see the strikethrough effect.

Help me debug my toggle and delete handlers

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is toggle_task.php:

<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$taskId = $_GET['id'] ?? null;

if ($taskId) {
    $stmt = $pdo->prepare("UPDATE tasks SET is_completed = NOT is_completed WHERE id = :id AND user_id = :user_id");
    $stmt->execute([':id' => $taskId, ':user_id' => $_SESSION['user_id']]);
}

header('Location: dashboard.php');
exit;

Here is delete_task.php:

<?php
session_start();
require_once 'config.php';

if (!isset($_SESSION['user_id'])) {
    header('Location: login.php');
    exit;
}

$taskId = $_GET['id'] ?? null;

if ($taskId) {
    $stmt = $pdo->prepare("DELETE FROM tasks WHERE id = :id AND user_id = :user_id");
    $stmt->execute([':id' => $taskId, ':user_id' => $_SESSION['user_id']]);
}

header('Location: dashboard.php');
exit;

Your task management app now has full CRUD functionality. Users can create, read, edit, and delete tasks, all protected by session-based authentication and ownership checks. Next up, you'll add some CSS styling to make everything look polished.

Style the Application with CSS

Your TaskFlow app is fully functional. Users can register, log in, create tasks, edit them, mark them complete, and delete them. But right now, everything is unstyled browser defaults.

In this step, you will create a style.css file that transforms the raw HTML into a polished, modern-looking application. You will style both the authentication pages and the task dashboard.

In this step, get ready to:
  • Style the login and registration forms with a centered card design and input styling.
  • Style the task dashboard with a task list, action buttons, and a strikethrough effect for completed tasks.
Style the login and registration forms

Your PHP files already reference style.css in their <link> tags. You just need to create the file and the styles will apply automatically.

  • In VS Code, right-click in the Explorer sidebar inside your taskflow folder.
  • Click New File and name it style.css.

The first section of CSS handles the global reset, page background, and the centered card layout for the login and registration pages.

  • Add the global styles and form layout by pasting this code into style.css:
/* Global reset removes default browser spacing */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

/* Base page styling */
body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background-color: #f5f5f5;
    color: #333;
    line-height: 1.6;
}

/* Centered card for login and register pages */
.auth-container {
    max-width: 400px;
    margin: 80px auto;
    background: #fff;
    padding: 40px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    text-align: center;
}

.auth-container h1 {
    color: #4a90d9;
    margin-bottom: 8px;
    font-size: 28px;
}

.auth-container h2 {
    margin-bottom: 24px;
    font-weight: 400;
    color: #666;
}

/* Card container for add/edit task forms */
.form-container {
    max-width: 500px;
    margin: 40px auto;
    background: #fff;
    padding: 40px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.form-container h2 {
    margin-bottom: 24px;
    color: #333;
}

/* Input and label styling for all forms */
.form-group {
    margin-bottom: 16px;
    text-align: left;
}

.form-group label {
    display: block;
    margin-bottom: 6px;
    font-weight: 500;
    color: #555;
}

.form-group input,
.form-group textarea {
    width: 100%;
    padding: 10px 12px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 14px;
    transition: border-color 0.3s;
}

.form-group input:focus,
.form-group textarea:focus {
    outline: none;
    border-color: #4a90d9;
}

What does this code do?

  • The global reset (*) removes all default margins and padding so every browser renders the page the same way.
  • .auth-container creates a white card centered on the page with a subtle shadow. This wraps the login and register forms.
  • .form-container is a slightly wider card used for the add and edit task pages.
  • The :focus styles highlight the active input field with a blue border so users know where they are typing.
  • Now add the button styles, alert messages, and auth link styling below the code you just pasted:
/* Base button styling shared by all buttons */
.btn {
    display: inline-block;
    padding: 10px 20px;
    border: none;
    border-radius: 4px;
    font-size: 14px;
    cursor: pointer;
    text-decoration: none;
    transition: background-color 0.3s;
}

/* Primary action button (submit, create) */
.btn-primary {
    background-color: #4a90d9;
    color: #fff;
    width: 100%;
    margin-top: 8px;
}

.btn-primary:hover {
    background-color: #357abd;
}

/* Secondary button (cancel) */
.btn-secondary {
    background-color: #6c757d;
    color: #fff;
    margin-top: 8px;
    margin-left: 8px;
}

.btn-secondary:hover {
    background-color: #5a6268;
}

/* Green button for marking tasks complete */
.btn-complete {
    background-color: #28a745;
    color: #fff;
    padding: 6px 12px;
    font-size: 12px;
}

.btn-complete:hover {
    background-color: #218838;
}

/* Blue button for editing tasks */
.btn-edit {
    background-color: #4a90d9;
    color: #fff;
    padding: 6px 12px;
    font-size: 12px;
}

.btn-edit:hover {
    background-color: #357abd;
}

/* Red button for deleting tasks */
.btn-delete {
    background-color: #dc3545;
    color: #fff;
    padding: 6px 12px;
    font-size: 12px;
}

.btn-delete:hover {
    background-color: #c82333;
}

/* Logout button in the header */
.btn-logout {
    background-color: #6c757d;
    color: #fff;
    padding: 8px 16px;
    font-size: 13px;
}

.btn-logout:hover {
    background-color: #5a6268;
}

/* Alert message boxes for errors and success */
.alert {
    padding: 12px;
    border-radius: 4px;
    margin-bottom: 16px;
    text-align: left;
}

.alert-error {
    background-color: #f8d7da;
    color: #721c24;
    border: 1px solid #f5c6cb;
}

.alert-success {
    background-color: #d4edda;
    color: #155724;
    border: 1px solid #c3e6cb;
}

/* Link below auth forms (e.g. "Already have an account?") */
.auth-link {
    margin-top: 16px;
    color: #666;
}

.auth-link a {
    color: #4a90d9;
    text-decoration: none;
}

.auth-link a:hover {
    text-decoration: underline;
}

What does this code do?

  • .btn defines shared button properties (padding, border-radius, cursor). Each variant class adds its own color.
  • The action buttons (.btn-complete, .btn-edit, .btn-delete) use smaller padding since they sit in a row next to each task.
  • .alert-error shows a red-tinted box for validation errors. .alert-success shows a green-tinted box for the registration success message.
  • Save style.css.
  • Open your browser and navigate to http://localhost/taskflow/login.php. You should see a white card centered on a light grey background, with styled input fields and a blue login button.

Page still looks unstyled?

  • Make sure your file is named exactly style.css (not styles.css or style.CSS). The filename must match the <link> tag in your PHP files.
  • Confirm the file is saved in C:\xampp\htdocs\taskflow\ alongside your PHP files, not in a subfolder.
  • Try a hard refresh with Ctrl+Shift+R to bypass the browser cache.

Why isn't my CSS loading on the login page?

Style the task dashboard

The dashboard is where users spend most of their time. Each task needs to be clearly readable, and completed tasks should look visually distinct from pending ones.

  • Add the dashboard and task list styles at the bottom of style.css:
/* Dashboard page wrapper */
.dashboard-container {
    max-width: 800px;
    margin: 0 auto;
    padding: 20px;
}

/* Header with app name and logout */
.dashboard-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 30px;
    padding-bottom: 16px;
    border-bottom: 1px solid #ddd;
}

.dashboard-header h1 {
    color: #4a90d9;
}

.dashboard-header nav {
    display: flex;
    align-items: center;
    gap: 16px;
}

/* Section with title and New Task button */
.tasks-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
}

.tasks-header .btn-primary {
    width: auto;
}

/* Empty state message */
.no-tasks {
    text-align: center;
    color: #888;
    padding: 40px;
    font-size: 16px;
}

/* Vertical list of task cards */
.task-list {
    display: flex;
    flex-direction: column;
    gap: 12px;
}

/* Individual task card with blue left border */
.task-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    background: #fff;
    padding: 16px 20px;
    border-radius: 6px;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
    border-left: 4px solid #4a90d9;
}

/* Completed tasks get green border and faded look */
.task-item.completed {
    border-left-color: #28a745;
    opacity: 0.7;
}

/* Strikethrough on completed task titles */
.task-item.completed .task-content h3 {
    text-decoration: line-through;
    color: #888;
}

.task-content h3 {
    margin-bottom: 4px;
    font-size: 16px;
}

.task-content p {
    color: #666;
    font-size: 14px;
    margin-bottom: 4px;
}

.task-content small {
    color: #999;
    font-size: 12px;
}

/* Action buttons row */
.task-actions {
    display: flex;
    gap: 8px;
    flex-shrink: 0;
}

What does this code do?

  • .task-item gives each task a white card with a blue left border. This makes each task stand out from the grey background.
  • .task-item.completed reduces the opacity to 0.7 and changes the left border to green. This makes completed tasks visually recede compared to pending ones.
  • The text-decoration: line-through on completed task titles gives users an instant visual cue that the task is done.
  • flex-shrink: 0 on .task-actions prevents the action buttons from getting squished when a task title is long.
  • Save style.css.
  • Log in to your app and navigate to the dashboard at http://localhost/taskflow/dashboard.php. You should see a header with the TaskFlow title and your username on the right, plus a styled task list below.
  • If you have tasks, try marking one as complete. The completed task should appear with a strikethrough title, green left border, and faded opacity.

Completed tasks don't look different?

  • Check that your dashboard.php adds the completed class to the task-item div when is_completed is true. Without that class, the CSS has nothing to target.
  • Make sure your toggle_task.php is working. Click the Complete button and refresh the page to see if the task status actually changed in the database.

Why aren't my completed task styles showing up?

✔️ Awesome, I've got everything!

Great. Double check you have saved style.css and your app looks polished in the browser.

ⓧ I'd like to double check the full code

Here is the complete style.css file. Make sure yours matches exactly:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background-color: #f5f5f5;
    color: #333;
    line-height: 1.6;
}

.auth-container {
    max-width: 400px;
    margin: 80px auto;
    background: #fff;
    padding: 40px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    text-align: center;
}

.auth-container h1 {
    color: #4a90d9;
    margin-bottom: 8px;
    font-size: 28px;
}

.auth-container h2 {
    margin-bottom: 24px;
    font-weight: 400;
    color: #666;
}

.form-container {
    max-width: 500px;
    margin: 40px auto;
    background: #fff;
    padding: 40px;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.form-container h2 {
    margin-bottom: 24px;
    color: #333;
}

.form-group {
    margin-bottom: 16px;
    text-align: left;
}

.form-group label {
    display: block;
    margin-bottom: 6px;
    font-weight: 500;
    color: #555;
}

.form-group input,
.form-group textarea {
    width: 100%;
    padding: 10px 12px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 14px;
    transition: border-color 0.3s;
}

.form-group input:focus,
.form-group textarea:focus {
    outline: none;
    border-color: #4a90d9;
}

.btn {
    display: inline-block;
    padding: 10px 20px;
    border: none;
    border-radius: 4px;
    font-size: 14px;
    cursor: pointer;
    text-decoration: none;
    transition: background-color 0.3s;
}

.btn-primary {
    background-color: #4a90d9;
    color: #fff;
    width: 100%;
    margin-top: 8px;
}

.btn-primary:hover {
    background-color: #357abd;
}

.btn-secondary {
    background-color: #6c757d;
    color: #fff;
    margin-top: 8px;
    margin-left: 8px;
}

.btn-secondary:hover {
    background-color: #5a6268;
}

.btn-complete {
    background-color: #28a745;
    color: #fff;
    padding: 6px 12px;
    font-size: 12px;
}

.btn-complete:hover {
    background-color: #218838;
}

.btn-edit {
    background-color: #4a90d9;
    color: #fff;
    padding: 6px 12px;
    font-size: 12px;
}

.btn-edit:hover {
    background-color: #357abd;
}

.btn-delete {
    background-color: #dc3545;
    color: #fff;
    padding: 6px 12px;
    font-size: 12px;
}

.btn-delete:hover {
    background-color: #c82333;
}

.btn-logout {
    background-color: #6c757d;
    color: #fff;
    padding: 8px 16px;
    font-size: 13px;
}

.btn-logout:hover {
    background-color: #5a6268;
}

.alert {
    padding: 12px;
    border-radius: 4px;
    margin-bottom: 16px;
    text-align: left;
}

.alert-error {
    background-color: #f8d7da;
    color: #721c24;
    border: 1px solid #f5c6cb;
}

.alert-success {
    background-color: #d4edda;
    color: #155724;
    border: 1px solid #c3e6cb;
}

.auth-link {
    margin-top: 16px;
    color: #666;
}

.auth-link a {
    color: #4a90d9;
    text-decoration: none;
}

.auth-link a:hover {
    text-decoration: underline;
}

.dashboard-container {
    max-width: 800px;
    margin: 0 auto;
    padding: 20px;
}

.dashboard-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 30px;
    padding-bottom: 16px;
    border-bottom: 1px solid #ddd;
}

.dashboard-header h1 {
    color: #4a90d9;
}

.dashboard-header nav {
    display: flex;
    align-items: center;
    gap: 16px;
}

.tasks-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
}

.tasks-header .btn-primary {
    width: auto;
}

.no-tasks {
    text-align: center;
    color: #888;
    padding: 40px;
    font-size: 16px;
}

.task-list {
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.task-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    background: #fff;
    padding: 16px 20px;
    border-radius: 6px;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
    border-left: 4px solid #4a90d9;
}

.task-item.completed {
    border-left-color: #28a745;
    opacity: 0.7;
}

.task-item.completed .task-content h3 {
    text-decoration: line-through;
    color: #888;
}

.task-content h3 {
    margin-bottom: 4px;
    font-size: 16px;
}

.task-content p {
    color: #666;
    font-size: 14px;
    margin-bottom: 4px;
}

.task-content small {
    color: #999;
    font-size: 12px;
}

.task-actions {
    display: flex;
    gap: 8px;
    flex-shrink: 0;
}

Secret mission

Add Priority Levels and Filtering

Your task manager works great, but all tasks look the same regardless of urgency. Can you add a priority system with color-coded badges and filtering so users can instantly spot what needs attention first?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally using XAMPP, so there are no ongoing costs.

Resources you used:

  • XAMPP services (Apache and MySQL) running on your machine.
  • The taskflow_db database stored in MariaDB.
  • The taskflow project folder at C:\xampp\htdocs\taskflow.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with TaskFlow.

  • XAMPP will continue running Apache and MySQL until you stop the services or restart your computer.
  • Your database and project files remain intact and accessible at http://localhost/taskflow/.
  • XAMPP does not start automatically on boot by default, so after a restart you will need to reopen XAMPP Control Panel and start the services again.

Pause - I'll come back to this later

Stop the running services to free up memory and ports, but keep all your files and database intact.

  • Open XAMPP Control Panel.
  • Click Stop next to Apache.
  • Click Stop next to MySQL.
  • Close XAMPP Control Panel.
  • When you want to resume, reopen XAMPP Control Panel and start both services again.

Delete - I don't want to use this again

Remove all project resources and optionally uninstall XAMPP entirely.

  • Open XAMPP Control Panel and make sure Apache and MySQL are both running (click Start if they are stopped).
  • Open your browser and navigate to http://localhost/phpmyadmin.
  • Select taskflow_db from the left sidebar.
  • Click the Operations tab at the top.
  • Under Remove database, click Drop the database (DROP) and confirm.
  • Stop both Apache and MySQL in XAMPP Control Panel.
  • Delete the project folder by opening File Explorer and navigating to C:\xampp\htdocs\. Right-click the taskflow folder and select Delete.
  • To uninstall XAMPP entirely, open Windows Settings.
  • Select Apps, then Installed apps.
  • Search for XAMPP, click the three-dot menu, and select Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a complete task management application from scratch using PHP and MySQL.

TaskFlow handles user registration, secure login, and full task management. That is a real full-stack web application.

You've learned how to:

  • Build a secure authentication system with bcrypt password hashing, PHP sessions, and session fixation prevention.
  • Design a relational MySQL database and query it safely using PDO prepared statements to prevent SQL injection.
  • Implement full CRUD operations (create, read, update, delete) with ownership verification so each user only sees their own tasks.
  • Added priority levels with color-coded badges and filtering to help users organize tasks by urgency.

Ready to quiz yourself?