How to Build a PHP CRUD Application From Scratch
CRUD — Create, Read, Update, Delete — is the backbone of almost every web application. In this tutorial, you’ll build a complete Task Manager from scratch using plain PHP, MySQL, and basic HTML/CSS. Every file is shown in full. No frameworks, no libraries, just pure code.
What You’ll Build
A working task manager where you can add, view, edit, and delete tasks. It uses PDO prepared statements to prevent SQL injection, CSRF tokens on every form, server-side validation, and htmlspecialchars() on all output to prevent XSS.
Project Structure
php-crud-app/
├── config.php # Database connection
├── helpers.php # CSRF, escaping, redirect functions
├── index.php # List all tasks (READ)
├── create.php # Add new task (CREATE)
├── edit.php # Edit task (UPDATE)
├── delete.php # Remove task (DELETE)
└── show.php # View single task (READ detail)
Seven files. That’s the whole app.
Step 1: Create the Database
Open your MySQL client and run:
CREATE DATABASE php_crud_app
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE php_crud_app;
CREATE TABLE tasks (
task_id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
task_title VARCHAR(255) NOT NULL,
task_description TEXT NULL,
task_status ENUM('pending','in_progress','completed') NOT NULL DEFAULT 'pending',
task_priority ENUM('low','medium','high') NOT NULL DEFAULT 'medium',
due_date DATE NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
Use utf8mb4 instead of utf8 — it supports emojis and all international characters. Use InnoDB because it supports transactions and foreign keys.
Step 2: Database Connection — config.php
PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
error_log('DB Error: ' . $e->getMessage());
http_response_code(500);
exit('Something went wrong.');
}
EMULATE_PREPARES = false forces real prepared statements at the MySQL level — stronger protection against SQL injection. The catch block logs the real error but only shows users a generic message.
Step 3: Helper Functions — helpers.php
<?php
declare(strict_types=1);
session_start();
function csrf_token(): string {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(string $token): bool {
return hash_equals(csrf_token(), $token);
}
function esc(string $value): string {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
function redirect(string $url, string $msg = '', string $type = 'success'): void {
if ($msg !== '') {
$_SESSION['flash_' . $type] = $msg;
}
header('Location: ' . $url);
exit;
}
The esc() function is a shortcut for htmlspecialchars(). Without it, you'll forget to escape output somewhere and open yourself up to XSS attacks. A short helper means you'll actually use it everywhere.
Step 4: List All Tasks — index.php (READ)
query("SELECT * FROM tasks ORDER BY created_at DESC");
$tasks = $stmt->fetchAll();
$pageTitle = 'All Tasks';
?>
<title></title>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
nav { background: #fff; border-bottom: 1px solid #ddd; padding: 15px 0; margin-bottom: 20px; }
nav .container { display: flex; justify-content: space-between; align-items: center; }
nav a { text-decoration: none; color: #333; }
nav .brand { font-size: 1.3em; font-weight: bold; }
.btn { display: inline-block; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 14px; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-sm { padding: 4px 10px; font-size: 13px; }
.btn-danger { background: #dc2626; color: #fff; }
.btn-danger:hover { background: #b91c1c; }
.flash { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }
.flash-success { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
.flash-error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
table { width: 100%; background: #fff; border-collapse: collapse; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
th { background: #f9fafb; text-align: left; padding: 12px 16px; font-size: 12px; text-transform: uppercase; color: #666; border-bottom: 1px solid #e5e7eb; }
td { padding: 12px 16px; border-bottom: 1px solid #f3f4f6; }
tr:hover { background: #f9fafb; }
a { color: #2563eb; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.badge-pending { background: #f3f4f6; color: #666; }
.badge-in_progress { background: #fef3c7; color: #92400e; }
.badge-completed { background: #dcfce7; color: #166534; }
.badge-low { background: #dbeafe; color: #1e40af; }
.badge-medium { background: #fed7aa; color: #9a3412; }
.badge-high { background: #fecaca; color: #991b1b; }
.actions form { display: inline; }
.empty { text-align: center; padding: 40px; color: #999; }
<nav>
<a href="index.php" class="brand">Task Manager
<a href="create.php" class="btn btn-primary">+ New Task
</nav>
No tasks yet. <a href="create.php">Create your first task
<table>
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Priority</th>
<th>Due Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="show.php?id="></td>
<td><span class="badge badge-"></span></td>
<td><span class="badge badge-"></span></td>
<td></td>
<td class="actions">
<a href="edit.php?id=" class="btn btn-sm">Edit
<input type="hidden" name="task_id" value="">
<input type="hidden" name="csrf_token" value="">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</td>
</tr>
</tbody>
</table>
Every piece of user data is wrapped in esc() before output. The delete form uses POST (never GET for destructive actions) with a CSRF token.
Step 5: Create a Task — create.php (CREATE)
'',
'task_description' => '',
'task_status' => 'pending',
'task_priority' => 'medium',
'due_date' => '',
];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf($_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit('Invalid request.');
}
$formData['task_title'] = trim($_POST['task_title'] ?? '');
$formData['task_description'] = trim($_POST['task_description'] ?? '');
$formData['task_status'] = $_POST['task_status'] ?? 'pending';
$formData['task_priority'] = $_POST['task_priority'] ?? 'medium';
$formData['due_date'] = $_POST['due_date'] ?? '';
if ($formData['task_title'] === '') {
$errors[] = 'Title is required.';
}
if (mb_strlen($formData['task_title']) > 255) {
$errors[] = 'Title must be 255 characters or fewer.';
}
if (!in_array($formData['task_status'], ['pending', 'in_progress', 'completed'], true)) {
$errors[] = 'Invalid status.';
}
if (!in_array($formData['task_priority'], ['low', 'medium', 'high'], true)) {
$errors[] = 'Invalid priority.';
}
if ($formData['due_date'] !== '' && !preg_match('/^d{4}-d{2}-d{2}$/', $formData['due_date'])) {
$errors[] = 'Invalid date format.';
}
if (empty($errors)) {
$sql = "INSERT INTO tasks (task_title, task_description, task_status, task_priority, due_date)
VALUES (:task_title, :task_description, :task_status, :task_priority, :due_date)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':task_title' => $formData['task_title'],
':task_description' => $formData['task_description'] ?: null,
':task_status' => $formData['task_status'],
':task_priority' => $formData['task_priority'],
':due_date' => $formData['due_date'] ?: null,
]);
redirect('index.php', 'Task created successfully!');
}
}
?>
<title>Create Task</title>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
nav { background: #fff; border-bottom: 1px solid #ddd; padding: 15px 0; margin-bottom: 20px; }
nav .nav-inner { max-width: 900px; margin: 0 auto; padding: 0 20px; display: flex; justify-content: space-between; align-items: center; }
nav a { text-decoration: none; color: #333; }
nav .brand { font-size: 1.3em; font-weight: bold; }
h1 { font-size: 1.5em; margin-bottom: 20px; }
.errors { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }
.errors li { margin-left: 16px; }
.form-card { background: #fff; padding: 24px; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 4px; color: #555; }
input[type="text"], input[type="date"], textarea, select {
width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 4px;
font-size: 14px; font-family: inherit;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.2); }
textarea { resize: vertical; min-height: 80px; }
.row { display: flex; gap: 16px; }
.row .form-group { flex: 1; }
.btn { display: inline-block; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 14px; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.cancel { color: #666; text-decoration: none; margin-left: 12px; }
<nav>
<a href="index.php" class="brand">Task Manager
</nav>
Create New Task
<input type="hidden" name="csrf_token" value="">
<label for="task_title">Title *</label>
<input type="text" id="task_title" name="task_title" value="" required maxlength="255">
<label for="task_description">Description</label>
<textarea id="task_description" name="task_description"></textarea>
<label for="task_status">Status</label>
<option value="pending" >Pending
<option value="in_progress" >In Progress
<option value="completed" >Completed
<label for="task_priority">Priority</label>
<option value="low" >Low
<option value="medium" >Medium
<option value="high" >High
<label for="due_date">Due Date</label>
<input type="date" id="due_date" name="due_date" value="">
<button type="submit" class="btn btn-primary">Create Task</button>
<a href="index.php" class="cancel">Cancel
The validation pattern: collect input, validate against strict rules, only insert if $errors is empty. The form re-populates with the user’s input on failure so they don’t lose their work. Every query uses prepared statements with named parameters.
Step 6: Edit a Task — edit.php (UPDATE)
prepare("SELECT * FROM tasks WHERE task_id = :id LIMIT 1");
$stmt->execute([':id' => $taskId]);
$task = $stmt->fetch();
if (!$task) {
http_response_code(404);
exit('Task not found.');
}
$errors = [];
$formData = $task;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf($_POST['csrf_token'] ?? '')) {
http_response_code(403);
exit('Invalid request.');
}
$formData['task_title'] = trim($_POST['task_title'] ?? '');
$formData['task_description'] = trim($_POST['task_description'] ?? '');
$formData['task_status'] = $_POST['task_status'] ?? 'pending';
$formData['task_priority'] = $_POST['task_priority'] ?? 'medium';
$formData['due_date'] = $_POST['due_date'] ?? '';
if ($formData['task_title'] === '') {
$errors[] = 'Title is required.';
}
if (mb_strlen($formData['task_title']) > 255) {
$errors[] = 'Title must be 255 characters or fewer.';
}
if (!in_array($formData['task_status'], ['pending', 'in_progress', 'completed'], true)) {
$errors[] = 'Invalid status.';
}
if (!in_array($formData['task_priority'], ['low', 'medium', 'high'], true)) {
$errors[] = 'Invalid priority.';
}
if (empty($errors)) {
$sql = "UPDATE tasks SET task_title = :task_title, task_description = :task_description,
task_status = :task_status, task_priority = :task_priority, due_date = :due_date
WHERE task_id = :task_id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':task_title' => $formData['task_title'],
':task_description' => $formData['task_description'] ?: null,
':task_status' => $formData['task_status'],
':task_priority' => $formData['task_priority'],
':due_date' => $formData['due_date'] ?: null,
':task_id' => $taskId,
]);
redirect('index.php', 'Task updated successfully!');
}
}
?>
<title>Edit Task</title>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
nav { background: #fff; border-bottom: 1px solid #ddd; padding: 15px 0; margin-bottom: 20px; }
nav .nav-inner { max-width: 900px; margin: 0 auto; padding: 0 20px; }
nav a { text-decoration: none; color: #333; font-size: 1.3em; font-weight: bold; }
h1 { font-size: 1.5em; margin-bottom: 20px; }
.errors { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }
.errors li { margin-left: 16px; }
.form-card { background: #fff; padding: 24px; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 4px; color: #555; }
input[type="text"], input[type="date"], textarea, select {
width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 4px;
font-size: 14px; font-family: inherit;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.2); }
textarea { resize: vertical; min-height: 80px; }
.row { display: flex; gap: 16px; }
.row .form-group { flex: 1; }
.btn { display: inline-block; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.cancel { color: #666; text-decoration: none; margin-left: 12px; }
<nav><a href="index.php">Task Manager</nav>
Edit Task
<input type="hidden" name="csrf_token" value="">
<label for="task_title">Title *</label>
<input type="text" id="task_title" name="task_title" value="" required maxlength="255">
<label for="task_description">Description</label>
<textarea id="task_description" name="task_description"></textarea>
<label for="task_status">Status</label>
<option value="pending" >Pending
<option value="in_progress" >In Progress
<option value="completed" >Completed
<label for="task_priority">Priority</label>
<option value="low" >Low
<option value="medium" >Medium
<option value="high" >High
<label for="due_date">Due Date</label>
<input type="date" id="due_date" name="due_date" value="">
<button type="submit" class="btn btn-primary">Update Task</button>
<a href="index.php" class="cancel">Cancel
The edit page fetches the existing record to pre-fill the form. On submission, it runs the same validation as create. The difference is UPDATE instead of INSERT, and we pass :task_id in the WHERE clause to target the correct row.
Step 7: View Single Task — show.php (READ Detail)
prepare("SELECT * FROM tasks WHERE task_id = :id LIMIT 1");
$stmt->execute([':id' => $taskId]);
$task = $stmt->fetch();
if (!$task) {
http_response_code(404);
exit('Task not found.');
}
?>
<title></title>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Arial, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
nav { background: #fff; border-bottom: 1px solid #ddd; padding: 15px 0; margin-bottom: 20px; }
nav .nav-inner { max-width: 900px; margin: 0 auto; padding: 0 20px; }
nav a { text-decoration: none; color: #333; font-size: 1.3em; font-weight: bold; }
.back { display: inline-block; margin-bottom: 16px; color: #666; text-decoration: none; font-size: 14px; }
.back:hover { color: #333; }
.card { background: #fff; padding: 24px; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card h1 { font-size: 1.5em; margin-bottom: 8px; }
.card p { color: #555; line-height: 1.6; margin-bottom: 20px; }
.meta { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; font-size: 14px; }
.meta span { color: #999; display: block; margin-bottom: 2px; }
.meta strong { color: #333; }
.edit-link { display: inline-block; margin-top: 20px; color: #2563eb; text-decoration: none; font-size: 14px; }
<nav><a href="index.php">Task Manager</nav>
<a href="index.php" class="back">← Back to list
<span>Status</span>
<span>Priority</span>
<span>Due Date</span>
<span>Created</span>
<a href="edit.php?id=" class="edit-link">Edit this task
filter_input() with FILTER_VALIDATE_INT ensures the ID is actually a number. If someone passes ?id=abc, it returns false and we exit with a 404.
Step 8: Delete a Task — delete.php (DELETE)
prepare("DELETE FROM tasks WHERE task_id = :id");
$stmt->execute([':id' => $taskId]);
redirect('index.php', 'Task deleted.');
No HTML here — it only processes the form and redirects. Delete is POST-only because GET requests can be triggered by bots, browser prefetch, or accidental clicks. The CSRF token proves the request came from your own form.
How to Run It
From the project root, start PHP’s built-in server:
cd php-crud-app
php -S localhost:8000
Then open http://localhost:8000/index.php in your browser.
Security Recap
Here’s every security measure built into this app:
- PDO prepared statements on every query — prevents SQL injection. User input never touches your SQL string directly.
- htmlspecialchars() on all output via the esc() helper — prevents cross-site scripting (XSS).
- CSRF tokens on every form — prevents attackers from tricking users into submitting forms from other domains.
- Server-side validation on every field — never trust the client. Anyone can bypass browser validation.
- POST-only deletes — destructive actions never happen via GET.
- filter_input() for URL parameters — validates IDs are actual integers before querying.
- Generic error messages — database errors are logged server-side, users only see a safe message.
What to Add Next
This covers the core CRUD pattern. To make it production-ready, add pagination with LIMIT and OFFSET, search and filter by status or keyword, user authentication with password_hash() and password_verify(), and HTTPS enforcement in your web server config.
If you want a head start, check out my ready-made PHP source code in the shop where every project comes with full CRUD functionality, documentation, and deployment instructions.

Discussion
Leave a reply