🚀 Lesson 21: Building a CRUD App (Mini Project)
It's time to bring everything together. In this lesson, you'll build a complete Task Manager application from scratch — a real, working web app with forms, database operations, search, and user feedback. This project draws on skills from across the course: forms and $_GET/$_POST (Lesson 11), input validation (Lesson 12), file includes (Lesson 13), sessions for flash messages (Lesson 15), OOP structure (Lessons 16–17), error handling (Lesson 18), and PDO (Lessons 19–20). Let's build something real.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Plan and structure a multi-file PHP application
- Build a complete CRUD interface: list, create, edit, and delete records
- Implement search and filtering on database results
- Use flash messages (via sessions) for user feedback after actions
- Apply the Post/Redirect/Get (PRG) pattern to prevent duplicate form submissions
- Organize code with includes, a Database class, and a TaskRepository
Estimated Time: 60 minutes
Prerequisites: Lessons 11–13, 15, 18–20
📑 In This Lesson
Project Overview
We're building a Task Manager — a web application where you can create tasks, view them in a list, edit details, mark them as complete, delete them, and search by title. It's a classic CRUD app that mirrors the patterns used in real-world PHP projects.
Features
- List tasks — filterable by status (all, pending, in progress, done)
- Search — find tasks by title keyword
- Create task — form with title, description, and priority
- Edit task — update any field, change status
- Delete task — with confirmation prompt
- Flash messages — success/error feedback after every action
- Input validation — server-side validation on all forms
index.php"] -->|"+ New Task"| CREATE["➕ Create
create.php"] LIST -->|"Edit"| EDIT["✏️ Edit
edit.php"] LIST -->|"Delete"| DELETE["🗑️ Delete
delete.php"] CREATE -->|"POST → PRG"| LIST EDIT -->|"POST → PRG"| LIST DELETE -->|"POST → PRG"| LIST
📖 The Post/Redirect/Get (PRG) Pattern
Every form submission in this app follows PRG: the form POSTs data, the server processes it and stores a flash message in the session, then redirects (302) to the list page, which displays the message. This prevents duplicate submissions if the user refreshes the page — the browser's last request was a GET, not a POST.
Project Structure
We'll organize the project into logical files. Every PHP application benefits from separating concerns — database logic, page templates, and shared layout.
task-manager/
├── config/
│ └── database.php ← Database credentials
├── classes/
│ ├── Database.php ← PDO Singleton (from Lessons 19–20)
│ └── TaskRepository.php ← All task SQL queries
├── includes/
│ ├── header.php ← HTML head, nav, opening <main>
│ ├── footer.php ← Closing </main>, footer, scripts
│ └── flash.php ← Flash message helper functions
├── index.php ← Task list (READ) + search
├── create.php ← New task form (CREATE)
├── edit.php ← Edit task form (UPDATE)
├── delete.php ← Delete handler (DELETE)
└── setup.sql ← Database schema
✅ Why This Structure?
- config/ — Credentials stay in one place, easy to change per environment
- classes/ — Database and repository logic separated from page rendering
- includes/ — Shared HTML layout avoids copy-pasting headers and footers
- Root .php files — One file per page/action, easy to follow
Database Setup
First, let's create the database table. Run this SQL in phpMyAdmin, MySQL Workbench, or the MySQL command line.
-- File: setup.sql
CREATE DATABASE IF NOT EXISTS task_manager
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE task_manager;
CREATE TABLE IF NOT EXISTS tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
status ENUM('pending', 'in_progress', 'done') NOT NULL DEFAULT 'pending',
priority ENUM('low', 'medium', 'high') NOT NULL DEFAULT 'medium',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Seed some sample data
INSERT INTO tasks (title, description, status, priority) VALUES
('Set up project structure', 'Create folders and initial files', 'done', 'high'),
('Design the database schema', 'Plan tables and relationships', 'done', 'high'),
('Build the task list page', 'Display all tasks with filters', 'in_progress', 'high'),
('Add create task form', 'Form with validation', 'pending', 'medium'),
('Implement edit functionality', 'Edit form pre-filled with data', 'pending', 'medium'),
('Add delete with confirmation', 'Prevent accidental deletes', 'pending', 'low'),
('Write documentation', 'README and code comments', 'pending', 'low');
Next, the database configuration file:
<?php
// File: config/database.php
return [
"host" => "localhost",
"dbname" => "task_manager",
"user" => "root",
"pass" => "", // Your MySQL password
];
The Database Class
This is the same Singleton class from Lessons 19–20, updated to read credentials from the config file.
<?php
// File: classes/Database.php
class Database {
private static ?PDO $instance = null;
private const OPTIONS = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
private function __construct() {}
private function __clone() {}
public static function getConnection(): PDO {
if (self::$instance === null) {
$config = require __DIR__ . "/../config/database.php";
$dsn = sprintf(
"mysql:host=%s;dbname=%s;charset=utf8mb4",
$config["host"],
$config["dbname"]
);
try {
self::$instance = new PDO(
$dsn,
$config["user"],
$config["pass"],
self::OPTIONS
);
} catch (PDOException $e) {
error_log("DB connection failed: " . $e->getMessage());
die("Database connection error. Please check your configuration.");
}
}
return self::$instance;
}
public static function run(string $sql, array $params = []): PDOStatement {
$stmt = self::getConnection()->prepare($sql);
$stmt->execute($params);
return $stmt;
}
}
The TaskRepository
The Repository pattern puts all SQL queries for a specific table (or entity) in one class. This keeps your page files clean — they call $repo->findAll() instead of writing raw SQL. If the schema changes, you update one file instead of hunting through every page.
<?php
// File: classes/TaskRepository.php
require_once __DIR__ . "/Database.php";
class TaskRepository {
private PDO $pdo;
public function __construct() {
$this->pdo = Database::getConnection();
}
// ---- READ ----
/** Get all tasks, optionally filtered by status and/or search query. */
public function findAll(?string $status = null, ?string $search = null): array {
$sql = "SELECT * FROM tasks WHERE 1=1";
$params = [];
if ($status && in_array($status, ["pending", "in_progress", "done"])) {
$sql .= " AND status = :status";
$params["status"] = $status;
}
if ($search) {
$sql .= " AND (title LIKE :search OR description LIKE :search)";
$params["search"] = "%" . $search . "%";
}
$sql .= " ORDER BY
CASE priority
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
END,
created_at DESC";
return Database::run($sql, $params)->fetchAll();
}
/** Get a single task by ID, or null if not found. */
public function findById(int $id): ?array {
$row = Database::run(
"SELECT * FROM tasks WHERE id = ?",
[$id]
)->fetch();
return $row ?: null;
}
/** Count tasks grouped by status. */
public function countByStatus(): array {
return Database::run(
"SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
)->fetchAll(PDO::FETCH_KEY_PAIR);
}
// ---- CREATE ----
/** Insert a new task and return its ID. */
public function create(string $title, ?string $description, string $priority): int {
Database::run(
"INSERT INTO tasks (title, description, priority)
VALUES (:title, :description, :priority)",
[
"title" => $title,
"description" => $description,
"priority" => $priority,
]
);
return (int) $this->pdo->lastInsertId();
}
// ---- UPDATE ----
/** Update a task's fields. Returns true if the row existed. */
public function update(int $id, array $data): bool {
$allowed = ["title", "description", "status", "priority"];
$fields = [];
$params = ["id" => $id];
foreach ($data as $key => $value) {
if (in_array($key, $allowed)) {
$fields[] = "$key = :$key";
$params[$key] = $value;
}
}
if (empty($fields)) {
return false;
}
$sql = "UPDATE tasks SET " . implode(", ", $fields) . " WHERE id = :id";
Database::run($sql, $params);
// Return true if the row existed (even if nothing changed)
return $this->findById($id) !== null;
}
// ---- DELETE ----
/** Delete a task by ID. Returns true if a row was deleted. */
public function delete(int $id): bool {
return Database::run(
"DELETE FROM tasks WHERE id = ?",
[$id]
)->rowCount() > 0;
}
}
📖 The "WHERE 1=1" Trick
In findAll(), we start with WHERE 1=1 — a condition that's always true. This lets us append additional conditions with AND without worrying about whether it's the first condition or not. Without it, we'd need conditional logic to decide between WHERE status = ? and AND status = ?. It's a common pattern in dynamically-built queries and has zero performance cost — the database optimizer removes it.
Shared Layout (Header & Footer)
Instead of duplicating HTML in every page, we use include files. Every page calls require header.php at the top and require footer.php at the bottom.
Header
<?php
// File: includes/header.php
// Start session for flash messages (must be before any output)
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($pageTitle ?? "Task Manager") ?></title>
<style>
/* Minimal embedded styles for the project */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6; color: #1e293b; background: #f8fafc;
padding: 0; margin: 0;
}
.container { max-width: 900px; margin: 0 auto; padding: 1rem; }
/* Navigation */
.site-nav {
background: #1e293b; color: #e2e8f0; padding: 1rem;
}
.site-nav .container {
display: flex; justify-content: space-between; align-items: center;
}
.site-nav a { color: #93c5fd; text-decoration: none; font-weight: 600; }
.site-nav a:hover { color: #bfdbfe; }
/* Cards and forms */
.card {
background: white; border-radius: 8px; padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 1.5rem;
}
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
.form-group input, .form-group textarea, .form-group select {
width: 100%; padding: 0.5rem; border: 1px solid #cbd5e1;
border-radius: 4px; font-size: 1rem;
}
.form-group textarea { min-height: 100px; resize: vertical; }
/* Buttons */
.btn {
display: inline-block; padding: 0.5rem 1rem; border-radius: 4px;
font-size: 0.9rem; font-weight: 600; text-decoration: none;
border: none; cursor: pointer; transition: background 0.2s;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover { background: #2563eb; }
.btn-success { background: #22c55e; color: white; }
.btn-success:hover { background: #16a34a; }
.btn-danger { background: #ef4444; color: white; }
.btn-danger:hover { background: #dc2626; }
.btn-secondary { background: #64748b; color: white; }
.btn-secondary:hover { background: #475569; }
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.8rem; }
/* Table */
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #e2e8f0; }
th { background: #f1f5f9; font-weight: 600; }
tr:hover { background: #f8fafc; }
/* Status badges */
.badge {
display: inline-block; padding: 0.2rem 0.6rem; border-radius: 12px;
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
}
.badge-pending { background: #fef3c7; color: #92400e; }
.badge-in_progress { background: #dbeafe; color: #1e40af; }
.badge-done { background: #dcfce7; color: #166534; }
/* Priority badges */
.priority-high { color: #dc2626; font-weight: 600; }
.priority-medium { color: #d97706; }
.priority-low { color: #6b7280; }
/* Flash messages */
.flash {
padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem;
font-weight: 500;
}
.flash-success { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
.flash-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
.flash-info { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
/* Filter bar */
.filter-bar {
display: flex; gap: 0.5rem; flex-wrap: wrap;
align-items: center; margin-bottom: 1rem;
}
.filter-bar a {
padding: 0.3rem 0.8rem; border-radius: 4px; text-decoration: none;
font-size: 0.85rem; color: #475569; background: #f1f5f9;
}
.filter-bar a:hover { background: #e2e8f0; }
.filter-bar a.active { background: #3b82f6; color: white; }
/* Search form */
.search-form {
display: flex; gap: 0.5rem; margin-bottom: 1rem;
}
.search-form input { flex: 1; }
/* Utilities */
.text-muted { color: #94a3b8; }
.text-center { text-align: center; }
.mb-1 { margin-bottom: 1rem; }
.mt-1 { margin-top: 1rem; }
.flex-between { display: flex; justify-content: space-between; align-items: center; }
.actions { display: flex; gap: 0.4rem; }
.error-text { color: #dc2626; font-size: 0.85rem; margin-top: 0.25rem; }
@media (max-width: 640px) {
table, thead, tbody, th, td, tr { display: block; }
th { display: none; }
td {
padding: 0.4rem 0.75rem; text-align: right;
border: none; position: relative; padding-left: 40%;
}
td::before {
content: attr(data-label);
position: absolute; left: 0.75rem; font-weight: 600; text-align: left;
}
tr { border-bottom: 2px solid #e2e8f0; margin-bottom: 0.5rem; padding: 0.5rem 0; }
}
</style>
</head>
<body>
<nav class="site-nav">
<div class="container">
<a href="index.php">📋 Task Manager</a>
<a href="create.php">+ New Task</a>
</div>
</nav>
<div class="container">
<h1 style="margin: 1.5rem 0 1rem;"><?= htmlspecialchars($pageTitle ?? "Task Manager") ?></h1>
Footer
<?php // File: includes/footer.php ?>
</div><!-- /.container -->
<footer style="text-align:center; padding:2rem 1rem; color:#94a3b8; font-size:0.85rem; margin-top:2rem; border-top:1px solid #e2e8f0;">
<p>Task Manager — PHP Foundations Mini Project</p>
</footer>
</body>
</html>
✅ How This Layout Works
Each page sets $pageTitle before including the header. The header outputs everything from <!DOCTYPE> through the <h1>. The footer closes out the HTML. Between them, each page file contains only its unique content — no boilerplate.
Flash Messages
A flash message is a one-time notification stored in the session. It survives one redirect, then disappears. This is how you show "Task created!" after a form submission without passing messages through URL parameters.
<?php
// File: includes/flash.php
/**
* Set a flash message in the session.
* Types: "success", "error", "info"
*/
function setFlash(string $type, string $message): void {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION["flash"] = [
"type" => $type,
"message" => $message,
];
}
/**
* Display and clear the flash message (if one exists).
* Call this in the page body where you want the message to appear.
*/
function showFlash(): void {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (isset($_SESSION["flash"])) {
$flash = $_SESSION["flash"];
$type = htmlspecialchars($flash["type"]);
$message = htmlspecialchars($flash["message"]);
echo "<div class=\"flash flash-$type\">$message</div>";
unset($_SESSION["flash"]); // One-time display
}
}
📖 The Flash Message Lifecycle
- User submits a form (POST to
create.php) - Server processes the request and calls
setFlash("success", "Task created!") - Server redirects to
index.php(PRG pattern) - The
index.phppage callsshowFlash()— the message appears showFlash()removes the message from the session — it won't appear again on refresh
Task List Page (Read)
This is the main page — it lists all tasks with status filters and a search bar.
<?php
// File: index.php
require_once "classes/TaskRepository.php";
require_once "includes/flash.php";
$repo = new TaskRepository();
// Get filter parameters from the URL
$statusFilter = $_GET["status"] ?? null;
$searchQuery = trim($_GET["q"] ?? "");
// Fetch tasks (filtered if params provided)
$tasks = $repo->findAll($statusFilter, $searchQuery ?: null);
$counts = $repo->countByStatus();
$totalTasks = array_sum($counts);
$pageTitle = "All Tasks";
require "includes/header.php";
?>
<?php showFlash(); ?>
<!-- Search bar -->
<form class="search-form" method="GET" action="index.php">
<?php if ($statusFilter): ?>
<input type="hidden" name="status" value="<?= htmlspecialchars($statusFilter) ?>">
<?php endif; ?>
<input type="text" name="q" placeholder="Search tasks..."
value="<?= htmlspecialchars($searchQuery) ?>">
<button type="submit" class="btn btn-primary">Search</button>
<?php if ($searchQuery): ?>
<a href="index.php<?= $statusFilter ? '?status=' . urlencode($statusFilter) : '' ?>"
class="btn btn-secondary">Clear</a>
<?php endif; ?>
</form>
<!-- Status filters -->
<div class="filter-bar">
<a href="index.php<?= $searchQuery ? '?q=' . urlencode($searchQuery) : '' ?>"
class="<?= !$statusFilter ? 'active' : '' ?>">
All (<?= $totalTasks ?>)
</a>
<a href="?status=pending<?= $searchQuery ? '&q=' . urlencode($searchQuery) : '' ?>"
class="<?= $statusFilter === 'pending' ? 'active' : '' ?>">
Pending (<?= $counts["pending"] ?? 0 ?>)
</a>
<a href="?status=in_progress<?= $searchQuery ? '&q=' . urlencode($searchQuery) : '' ?>"
class="<?= $statusFilter === 'in_progress' ? 'active' : '' ?>">
In Progress (<?= $counts["in_progress"] ?? 0 ?>)
</a>
<a href="?status=done<?= $searchQuery ? '&q=' . urlencode($searchQuery) : '' ?>"
class="<?= $statusFilter === 'done' ? 'active' : '' ?>">
Done (<?= $counts["done"] ?? 0 ?>)
</a>
</div>
<!-- Task table -->
<?php if (empty($tasks)): ?>
<div class="card text-center">
<p class="text-muted">No tasks found.
<a href="create.php">Create one?</a>
</p>
</div>
<?php else: ?>
<div class="card" style="padding: 0; overflow-x: auto;">
<table>
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Priority</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($tasks as $task): ?>
<tr>
<td data-label="Title">
<strong><?= htmlspecialchars($task["title"]) ?></strong>
<?php if ($task["description"]): ?>
<br><small class="text-muted">
<?= htmlspecialchars(
mb_strimwidth($task["description"], 0, 80, "...")
) ?>
</small>
<?php endif; ?>
</td>
<td data-label="Status">
<span class="badge badge-<?= $task['status'] ?>">
<?= str_replace("_", " ", $task["status"]) ?>
</span>
</td>
<td data-label="Priority">
<span class="priority-<?= $task['priority'] ?>">
<?= ucfirst($task["priority"]) ?>
</span>
</td>
<td data-label="Created">
<small><?= date("M j, Y", strtotime($task["created_at"])) ?></small>
</td>
<td data-label="Actions">
<div class="actions">
<a href="edit.php?id=<?= $task['id'] ?>"
class="btn btn-primary btn-sm">Edit</a>
<a href="delete.php?id=<?= $task['id'] ?>"
class="btn btn-danger btn-sm"
onclick="return confirm('Delete this task?')">Delete</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php require "includes/footer.php"; ?>
⚠️ Always Escape Output
Notice that every database value displayed in HTML goes through htmlspecialchars(). This prevents Cross-Site Scripting (XSS) attacks — even if someone saved <script>alert('hacked')</script> as a task title, it would display as harmless text. We'll cover this in depth in Lesson 23.
Create Task Page
This page handles two requests: GET (display the empty form) and POST (process the submission). The same file handles both — a common PHP pattern.
<?php
// File: create.php
require_once "classes/TaskRepository.php";
require_once "includes/flash.php";
$repo = new TaskRepository();
$errors = [];
$old = [
"title" => "",
"description" => "",
"priority" => "medium",
];
// ---- Handle form submission ----
if ($_SERVER["REQUEST_METHOD"] === "POST") {
// 1. Collect and trim input
$old["title"] = trim($_POST["title"] ?? "");
$old["description"] = trim($_POST["description"] ?? "");
$old["priority"] = $_POST["priority"] ?? "medium";
// 2. Validate
if ($old["title"] === "") {
$errors["title"] = "Title is required.";
} elseif (mb_strlen($old["title"]) > 255) {
$errors["title"] = "Title must be 255 characters or fewer.";
}
if (!in_array($old["priority"], ["low", "medium", "high"])) {
$errors["priority"] = "Invalid priority selected.";
}
// 3. If valid → insert, flash, redirect (PRG)
if (empty($errors)) {
$newId = $repo->create(
$old["title"],
$old["description"] ?: null,
$old["priority"]
);
setFlash("success", "Task \"{$old['title']}\" created! (ID: $newId)");
header("Location: index.php");
exit;
}
}
// ---- Display the form ----
$pageTitle = "New Task";
require "includes/header.php";
?>
<?php if (!empty($errors)): ?>
<div class="flash flash-error">
Please fix the errors below.
</div>
<?php endif; ?>
<div class="card">
<form method="POST" action="create.php">
<div class="form-group">
<label for="title">Title *</label>
<input type="text" id="title" name="title"
value="<?= htmlspecialchars($old['title']) ?>"
placeholder="What needs to be done?" required>
<?php if (isset($errors["title"])): ?>
<p class="error-text"><?= htmlspecialchars($errors["title"]) ?></p>
<?php endif; ?>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"
placeholder="Add details (optional)"><?= htmlspecialchars($old['description']) ?></textarea>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="low" <?= $old["priority"] === "low" ? "selected" : "" ?>>
Low
</option>
<option value="medium" <?= $old["priority"] === "medium" ? "selected" : "" ?>>
Medium
</option>
<option value="high" <?= $old["priority"] === "high" ? "selected" : "" ?>>
High
</option>
</select>
</div>
<div>
<button type="submit" class="btn btn-success">Create Task</button>
<a href="index.php" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<?php require "includes/footer.php"; ?>
✅ Key Patterns in This Page
- Sticky form — If validation fails, the form re-displays with the user's input preserved via
$oldvalues - Server-side validation — Even though we have
requiredon the HTML input, we validate again in PHP because HTML attributes can be bypassed - PRG redirect — After a successful insert, we redirect to
index.phpto prevent double submission on refresh - Whitelist validation — Priority is checked against a list of allowed values with
in_array()
Edit Task Page
The edit page is similar to create, but it pre-fills the form with existing data and includes a status dropdown.
<?php
// File: edit.php
require_once "classes/TaskRepository.php";
require_once "includes/flash.php";
$repo = new TaskRepository();
$errors = [];
// Get the task ID from the URL
$id = filter_input(INPUT_GET, "id", FILTER_VALIDATE_INT);
if (!$id) {
setFlash("error", "Invalid task ID.");
header("Location: index.php");
exit;
}
// Load the existing task
$task = $repo->findById($id);
if (!$task) {
setFlash("error", "Task not found.");
header("Location: index.php");
exit;
}
// Use task data as defaults (overwritten by POST data on submit)
$old = $task;
// ---- Handle form submission ----
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$old["title"] = trim($_POST["title"] ?? "");
$old["description"] = trim($_POST["description"] ?? "");
$old["status"] = $_POST["status"] ?? $task["status"];
$old["priority"] = $_POST["priority"] ?? $task["priority"];
// Validate
if ($old["title"] === "") {
$errors["title"] = "Title is required.";
} elseif (mb_strlen($old["title"]) > 255) {
$errors["title"] = "Title must be 255 characters or fewer.";
}
if (!in_array($old["status"], ["pending", "in_progress", "done"])) {
$errors["status"] = "Invalid status.";
}
if (!in_array($old["priority"], ["low", "medium", "high"])) {
$errors["priority"] = "Invalid priority.";
}
if (empty($errors)) {
$repo->update($id, [
"title" => $old["title"],
"description" => $old["description"] ?: null,
"status" => $old["status"],
"priority" => $old["priority"],
]);
setFlash("success", "Task \"{$old['title']}\" updated!");
header("Location: index.php");
exit;
}
}
$pageTitle = "Edit Task";
require "includes/header.php";
?>
<?php if (!empty($errors)): ?>
<div class="flash flash-error">Please fix the errors below.</div>
<?php endif; ?>
<div class="card">
<form method="POST" action="edit.php?id=<?= $id ?>">
<div class="form-group">
<label for="title">Title *</label>
<input type="text" id="title" name="title"
value="<?= htmlspecialchars($old['title']) ?>" required>
<?php if (isset($errors["title"])): ?>
<p class="error-text"><?= htmlspecialchars($errors["title"]) ?></p>
<?php endif; ?>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description"><?= htmlspecialchars($old['description'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" name="status">
<option value="pending" <?= $old["status"] === "pending" ? "selected" : "" ?>>
Pending
</option>
<option value="in_progress" <?= $old["status"] === "in_progress" ? "selected" : "" ?>>
In Progress
</option>
<option value="done" <?= $old["status"] === "done" ? "selected" : "" ?>>
Done
</option>
</select>
</div>
<div class="form-group">
<label for="priority">Priority</label>
<select id="priority" name="priority">
<option value="low" <?= $old["priority"] === "low" ? "selected" : "" ?>>
Low
</option>
<option value="medium" <?= $old["priority"] === "medium" ? "selected" : "" ?>>
Medium
</option>
<option value="high" <?= $old["priority"] === "high" ? "selected" : "" ?>>
High
</option>
</select>
</div>
<div>
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="index.php" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="card text-muted" style="font-size: 0.85rem;">
<p>Created: <?= $task["created_at"] ?> | Last updated: <?= $task["updated_at"] ?> | ID: <?= $task["id"] ?></p>
</div>
<?php require "includes/footer.php"; ?>
📖 Edit vs. Create — What's Different?
- The edit page loads existing data with
findById()and uses it as form defaults - It includes a status field (new tasks always start as "pending", but existing tasks can be changed)
- The form action includes
?id=Xso the POST handler knows which record to update - It validates the task ID from the URL with
filter_input()and redirects if invalid
Delete Task Action
Delete doesn't need its own form page — it's triggered from the task list with a JavaScript confirm() dialog. The delete.php file simply receives the ID via GET, performs the delete, and redirects back.
<?php
// File: delete.php
require_once "classes/TaskRepository.php";
require_once "includes/flash.php";
$repo = new TaskRepository();
// Validate the task ID
$id = filter_input(INPUT_GET, "id", FILTER_VALIDATE_INT);
if (!$id) {
setFlash("error", "Invalid task ID.");
header("Location: index.php");
exit;
}
// Attempt to delete
$task = $repo->findById($id);
if (!$task) {
setFlash("error", "Task not found.");
header("Location: index.php");
exit;
}
$deleted = $repo->delete($id);
if ($deleted) {
setFlash("success", "Task \"{$task['title']}\" deleted.");
} else {
setFlash("error", "Could not delete the task.");
}
header("Location: index.php");
exit;
⚠️ Security Note: GET for Deletes
Using GET for a destructive action (delete) isn't ideal — a malicious link could trick a user into deleting a task. In a production app, you'd use a POST form with a CSRF token (covered in Lesson 23). For this learning project, the confirm() dialog and server-side ID validation provide basic protection. The important thing is the pattern — validate, act, flash, redirect.
💡 Production Improvement: Replace the GET delete link with a small POST form containing a hidden id field and a CSRF token. The delete button submits the form. This prevents both accidental and malicious deletes.
Hands-On Challenges
You've seen the complete application. Now try building it yourself and extending it with these challenges.
🏋️ Challenge 1: Build the App from Scratch
Objective: Recreate the Task Manager using the code in this lesson as your guide — but type it yourself, don't copy-paste.
Steps:
- Create the folder structure:
config/,classes/,includes/ - Run
setup.sqlto create the database and table - Build
Database.phpandTaskRepository.php - Create the layout files (
header.php,footer.php,flash.php) - Build each page:
index.php,create.php,edit.php,delete.php - Test every feature: create, edit, change status, delete, search, filter
💡 Hint
Build incrementally. Start with Database.php, then TaskRepository with just findAll() and create(). Get the list page working first, then add create, then edit, then delete. Add search last.
🏋️ Challenge 2: Add a Due Date
Objective: Add a due_date column (DATE, nullable) to the tasks table and integrate it throughout the app.
Requirements:
- Add
due_date DATE DEFAULT NULLto the tasks table (use ALTER TABLE) - Add a date input to both the create and edit forms
- Display the due date in the task list — highlight overdue tasks in red
- Update
TaskRepositoryto includedue_datein create and update - Add a filter for "overdue" tasks (due date is past and status is not "done")
💡 Hint
Use <input type="date"> for the form field. For the overdue filter, add a SQL condition: due_date < CURDATE() AND status != 'done'. Use PHP's strtotime() and date() to compare dates for the red highlighting in the list view.
🏋️ Challenge 3: Add Pagination
Objective: Instead of showing all tasks at once, display 10 per page with "Previous" and "Next" links.
Requirements:
- Accept a
pageGET parameter (default to 1) - Modify
findAll()to accept$limitand$offsetparameters - Add a
countAll()method that returns the total number of matching tasks - Calculate total pages:
ceil($total / $perPage) - Display page navigation links below the table
- Preserve search and filter parameters in pagination links
💡 Hint
Calculate offset as ($page - 1) * $perPage. Use bindValue() with PDO::PARAM_INT for LIMIT and OFFSET. Build pagination URLs with http_build_query() to preserve all existing GET parameters.
🎯 Quick Quiz
Question 1: What is the Post/Redirect/Get (PRG) pattern, and why is it important?
Question 2: Why do we use htmlspecialchars() when displaying database values in HTML?
Question 3: What does the "WHERE 1=1" trick accomplish in dynamic queries?
Question 4: What's the purpose of a flash message?
Question 5: Why is using GET for delete operations a security concern?
Summary
🎉 Key Takeaways
- Project structure matters: Separate config, classes, includes, and page files for maintainability.
- Repository pattern: Put all SQL queries for an entity in one class — cleaner pages, easier testing, one place to update.
- PRG (Post/Redirect/Get): After a form POST, always redirect with
header("Location: ...")to prevent duplicate submissions. - Flash messages: Store one-time notifications in
$_SESSION, display once, then delete. Perfect for post-action feedback. - Sticky forms: On validation failure, re-display the form with the user's input preserved — don't make them retype everything.
- Output escaping: Every database value displayed in HTML must go through
htmlspecialchars()to prevent XSS. - Input validation: Always validate on the server side, even when you have HTML5 validation attributes.
- Filter with
filter_input(): Validate GET parameters (like IDs) before using them in database queries.
📚 Additional Resources
- PHP Manual: $_SESSION
- Wikipedia: Post/Redirect/Get Pattern
- PHP Manual: filter_input()
- PHP Manual: htmlspecialchars()
🚀 What's Next?
Your Task Manager is functional, but we haven't covered some important features: file uploads and email. In Lesson 22: File Uploads & Email, you'll learn to handle $_FILES, validate file types and sizes, move uploaded files safely, and send email from PHP. Then Lesson 23 covers the security fundamentals (SQL injection, XSS, CSRF) that turn a working app into a safe app.
🎉 Congratulations!
You've built a real PHP web application! This Task Manager uses every core skill from the course — forms, validation, sessions, OOP, error handling, and PDO. The patterns you've learned here (CRUD, PRG, flash messages, repository pattern, output escaping) are the same patterns used in professional PHP applications and frameworks like Laravel.