What You Are Building
The project is a Contact Manager: a small multi-user application where a person registers, signs in, and keeps a private list of contacts they can add, edit, search and delete. It is deliberately unglamorous, because every single concept in this course appears in it naturally, and because the shape is the same as almost any real CRUD application — an inventory list, a fee register, a complaints tracker.
"CRUD" is the standard shorthand for Create, Read, Update, Delete: the four things an application does to a record. Get those four working correctly, with proper validation and proper ownership checks, and you have the skeleton of most business software.
Two requirements make it more than a toy. It is multi-user, so every query must be scoped to the person asking — the single most commonly missed check in student projects. And it is public-facing, so every input is validated, every query is parameterised, and every output is escaped.
Build it in the order below. Each step produces something you can open in a browser and see working, which is far more motivating than writing the whole thing and then debugging it all at once.
- Set up the folder layout, the bootstrap file and the database connection
- Create the two tables, and confirm you can query them from phpMyAdmin
- Build registration and login:
password_hash(), sessions,session_regenerate_id() - Build the protected contacts list, scoped to the logged-in user
- Add the create form: CSRF token, validation, sticky fields, redirect on success
- Add edit and delete, both scoped by owner as well as by id
- Add search and pagination, with an allow-list for the sort column
- Work through the deployment checklist from the previous lesson before publishing
- Use git from the first file, and commit after each of those steps. When step five breaks something that worked in step four, being able to see exactly what changed is worth far more than the minute it costs.
The Database Schema
Two tables are enough. users holds accounts; contacts holds records, each belonging to exactly one user through a user_id column. That column is what every query will scope on, and it is what makes the application multi-user rather than shared.
A few column choices are worth explaining. password_hash is VARCHAR(255) even though today's hashes are 60 characters, because PHP's default algorithm is allowed to change and a column that is too narrow would truncate hashes and lock everyone out. Email is VARCHAR(254) with a UNIQUE index, which enforces one account per address at the database level — a rule your PHP check can race against, but the database cannot.
The character set is utf8mb4 on both tables, matching the connection charset from the PDO lesson, so names in any Indian script store and sort correctly. Phone numbers are stored as strings, not integers, because they are labels rather than quantities and leading zeros and plus signs matter.
The foreign key with ON DELETE CASCADE means deleting a user automatically removes their contacts, so you can never end up with orphaned rows pointing at an account that no longer exists. The index on user_id is what keeps the list query fast once there are many rows, since every query filters on it.
Create these in phpMyAdmin or from the MySQL command line, and run a couple of SELECT statements by hand before writing any PHP. Confirming the schema works on its own means that when the PHP fails, you know the problem is in the PHP.
-- schema.sql
CREATE DATABASE IF NOT EXISTS contacts_app
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_unicode_ci;
USE contacts_app;
CREATE TABLE users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(80) NOT NULL,
email VARCHAR(254) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE contacts (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
name VARCHAR(80) NOT NULL,
email VARCHAR(254) NULL,
phone VARCHAR(20) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_contacts_user (user_id),
KEY idx_contacts_name (name),
CONSTRAINT fk_contacts_user
FOREIGN KEY (user_id) REFERENCES users (id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - InnoDB is the storage engine you want, and it is the default in modern MySQL. It is what makes foreign keys and transactions work — the older MyISAM engine silently ignores both, which is a confusing way to discover that your constraints were never being enforced.
Layout and Bootstrap
Only the public/ folder should be reachable by URL. Everything else — classes, configuration, views, the database credentials — sits one level above, where no request can touch it. Point your web server's document root at public/, or during development run php -S localhost:8000 -t public.
A single bootstrap.php, required by every entry point, is where the repeated setup lives: error reporting for the current environment, session configuration and start, the database connection, and any small helper you use everywhere. Doing this once means you cannot forget it on one page — and a page that forgot session_start() or the CSRF setup is exactly the page an attacker will find.
Put the e() escaping helper here too. Its whole purpose is to make escaping so short to type that you never skip it, and that only works if it is available on every page without thinking about it.
Note the order inside the bootstrap. Error configuration first, so that anything failing afterwards is reported the way you intended. Session settings before session_start(), because the cookie parameters are used when the session begins. And absolutely no output anywhere in this file, so that redirects and session cookies still work in the pages that include it.
<?php
// bootstrap.php (one level above public/)
declare(strict_types=1);
$config = require __DIR__ . '/config/config.php';
// 1. Errors: loud in development, silent and logged in production
if ($config['env'] === 'production') {
ini_set('display_errors', '0');
ini_set('log_errors', '1');
} else {
ini_set('display_errors', '1');
}
error_reporting(E_ALL);
// 2. Session, configured before it starts
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => $config['env'] === 'production',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
// 3. Database
$dsn = "mysql:host={$config['db']['host']};dbname={$config['db']['name']};charset=utf8mb4";
$pdo = new PDO($dsn, $config['db']['user'], $config['db']['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
// 4. Helpers used on every page
function e(?string $value): string {
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
}
function csrfToken(): string {
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function requireCsrf(): void {
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
http_response_code(419);
exit('Your session expired. Please reload and try again.');
}
}
function requireLogin(): int {
if (!isset($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
return (int) $_SESSION['user_id'];
} - There is no closing
?>at the end of this file, and that is deliberate. A blank line after a closing tag is output, and output beforesession_start()orheader()breaks both. Leave the tag off in every file that contains only PHP.
The Contact Repository
All the SQL for contacts lives in one class. Each method is named after what the application does, takes the owning user's id as a parameter, and includes that id in the query. Because the check exists in one file rather than in eight page templates, it cannot be forgotten in one of them.
Look closely at find(), update() and delete(). Every one carries AND user_id = ? alongside the record id. Without that, anyone who changes the number in edit.php?id=7 can read and modify a stranger's contacts — the query is perfectly parameterised and the application is still wide open. Parameter binding protects you from injection; the ownership condition protects you from everything else.
The search method shows the safe way to handle a user-supplied sort. The wildcards for LIKE go into the bound value, never into the SQL. The sort column is chosen from an allow-list, because a column name cannot be a placeholder. And LIMIT and OFFSET are bound with PDO::PARAM_INT, which is required once emulated prepares are switched off.
Returning plain arrays here is a deliberate simplification; you could return objects with PDO::FETCH_CLASS if you prefer. What matters is that the pages above never see SQL, so changing a column name or adding an index is a one-file job.
<?php
// src/ContactRepository.php
declare(strict_types=1);
final class ContactRepository
{
private const SORTABLE = ['name' => 'name', 'created' => 'created_at'];
public function __construct(private PDO $db) {}
public function find(int $id, int $userId): ?array
{
$stmt = $this->db->prepare(
'SELECT * FROM contacts WHERE id = ? AND user_id = ?'
);
$stmt->execute([$id, $userId]);
return $stmt->fetch() ?: null;
}
public function search(int $userId, string $term = '', string $sort = 'name',
int $limit = 20, int $offset = 0): array
{
$column = self::SORTABLE[$sort] ?? 'name';
$sql = "SELECT id, name, email, phone, created_at
FROM contacts
WHERE user_id = :uid
AND (:term = '' OR name LIKE :like OR email LIKE :like2)
ORDER BY $column ASC
LIMIT :lim OFFSET :off";
$stmt = $this->db->prepare($sql);
$stmt->bindValue(':uid', $userId, PDO::PARAM_INT);
$stmt->bindValue(':term', $term);
$stmt->bindValue(':like', '%' . $term . '%');
$stmt->bindValue(':like2', '%' . $term . '%');
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function countFor(int $userId): int
{
$stmt = $this->db->prepare('SELECT COUNT(*) FROM contacts WHERE user_id = ?');
$stmt->execute([$userId]);
return (int) $stmt->fetchColumn();
}
public function create(int $userId, array $data): int
{
$stmt = $this->db->prepare(
'INSERT INTO contacts (user_id, name, email, phone, notes)
VALUES (:uid, :name, :email, :phone, :notes)'
);
$stmt->execute([
'uid' => $userId,
'name' => $data['name'],
'email' => $data['email'] !== '' ? $data['email'] : null,
'phone' => $data['phone'] !== '' ? $data['phone'] : null,
'notes' => $data['notes'] !== '' ? $data['notes'] : null,
]);
return (int) $this->db->lastInsertId();
}
public function update(int $id, int $userId, array $data): bool
{
$stmt = $this->db->prepare(
'UPDATE contacts SET name = :name, email = :email,
phone = :phone, notes = :notes
WHERE id = :id AND user_id = :uid'
);
return $stmt->execute([
'name' => $data['name'], 'email' => $data['email'] ?: null,
'phone' => $data['phone'] ?: null, 'notes' => $data['notes'] ?: null,
'id' => $id, 'uid' => $userId,
]);
}
public function delete(int $id, int $userId): bool
{
$stmt = $this->db->prepare('DELETE FROM contacts WHERE id = ? AND user_id = ?');
$stmt->execute([$id, $userId]);
return $stmt->rowCount() > 0;
}
} - Storing an empty optional field as SQL
NULLrather than an empty string is worth the small effort. NULL means "not provided", which is a different fact from "provided as blank", and it makes queries such asWHERE phone IS NOT NULLmean what you expect.
The Add Form, End to End
This single page ties together nearly everything in the course, and the order of operations is what makes it correct: require a login, verify the CSRF token, validate and collect errors, save, set a flash message, redirect, exit. Only after all of that does any HTML appear.
Validation collects every problem into an array keyed by field name, so all of them can be shown at once next to the fields they belong to. The submitted values are kept in $old and printed back into the form, escaped, so a failed submission never costs the user their typing.
The redirect after a successful save is the Post/Redirect/Get pattern. Without it, a refresh re-submits the form and creates a duplicate contact. With it, the browser's history holds a harmless GET and refreshing does nothing surprising.
Notice that every single value printed into the page goes through e() — the field values, the error messages, the CSRF token. That consistency is the point. Escaping most values and forgetting one is exactly as broken as escaping none.
The edit page is the same file with two changes: it loads the existing record first, using find($id, $userId) so a mismatched owner produces a 404, and it calls update() instead of create(). Delete should be a POST form with its own CSRF token, never a link, so that nothing can trigger it by merely following a URL.
<?php
// public/contact-add.php
require __DIR__ . '/../bootstrap.php';
require __DIR__ . '/../src/ContactRepository.php';
$userId = requireLogin();
$repo = new ContactRepository($pdo);
$errors = [];
$old = ['name' => '', 'email' => '', 'phone' => '', 'notes' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
requireCsrf();
foreach (array_keys($old) as $field) {
$old[$field] = trim($_POST[$field] ?? '');
}
if ($old['name'] === '') {
$errors['name'] = 'A name is required.';
} elseif (mb_strlen($old['name']) > 80) {
$errors['name'] = 'Name must be 80 characters or fewer.';
}
if ($old['email'] !== '' && !filter_var($old['email'], FILTER_VALIDATE_EMAIL)) {
$errors['email'] = 'That email address does not look valid.';
}
if ($old['phone'] !== '' && !preg_match('/^[0-9+\- ]{6,20}$/', $old['phone'])) {
$errors['phone'] = 'Phone may contain digits, spaces, + and - only.';
}
if (mb_strlen($old['notes']) > 1000) {
$errors['notes'] = 'Notes must be 1000 characters or fewer.';
}
if ($errors === []) {
$repo->create($userId, $old);
$_SESSION['flash'] = 'Contact saved.';
header('Location: /contacts.php');
exit;
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Add contact</title></head>
<body>
<h1>Add a contact</h1>
<form method="post" action="">
<input type="hidden" name="csrf" value="<?= e(csrfToken()) ?>">
<label for="name">Name *</label>
<input type="text" id="name" name="name" maxlength="80" required
value="<?= e($old['name']) ?>">
<?php if (isset($errors['name'])): ?>
<p class="error"><?= e($errors['name']) ?></p>
<?php endif; ?>
<label for="email">Email</label>
<input type="email" id="email" name="email" value="<?= e($old['email']) ?>">
<?php if (isset($errors['email'])): ?>
<p class="error"><?= e($errors['email']) ?></p>
<?php endif; ?>
<label for="phone">Phone</label>
<input type="text" id="phone" name="phone" value="<?= e($old['phone']) ?>">
<?php if (isset($errors['phone'])): ?>
<p class="error"><?= e($errors['phone']) ?></p>
<?php endif; ?>
<label for="notes">Notes</label>
<textarea id="notes" name="notes" rows="4"><?= e($old['notes']) ?></textarea>
<button type="submit">Save contact</button>
<a href="/contacts.php">Cancel</a>
</form>
</body>
</html> - The phone pattern here is deliberately loose. Real phone numbers vary more than people expect — country codes, extensions, spacing conventions — and an over-strict rule rejects valid data from real users. Validate only what you genuinely need to rely on.
The List Page, and Where to Take It Next
The list page reads the search and page parameters from the URL, asks the repository for a page of results, and renders them. It is short, because all the SQL is elsewhere and all the escaping goes through one helper.
Two details in it are worth copying into your own projects. The search term is printed back into the search box so the user can see what they searched for, escaped like everything else. And the empty state is handled explicitly — a table with headers and no rows looks broken, while "No contacts yet. Add your first one." tells someone exactly what to do.
Once this works, the natural extensions each teach something new rather than just adding bulk. A CSV export uses fputcsv() to php://output. A photo per contact uses the upload validation from the file-handling lesson. Tags need a second table and a join. A "remember me" cookie needs the hashed-token approach from the sessions lesson. A JSON endpoint returning the same data teaches you why array_values() on a filtered array matters.
Whatever you add, run through the deployment checklist from the previous lesson before putting it online: errors hidden and logged, credentials outside the web root and out of git, HTTPS on, every query parameterised and scoped, every output escaped, and a backup you have actually tested restoring.
That is the whole course, applied. If you can build this and explain why each security decision is there, you can build most small business applications in PHP — and you are ready to pick up Laravel or Symfony, which formalise exactly these patterns rather than replacing them.
<?php
// public/contacts.php
require __DIR__ . '/../bootstrap.php';
require __DIR__ . '/../src/ContactRepository.php';
$userId = requireLogin();
$repo = new ContactRepository($pdo);
$term = trim($_GET['q'] ?? '');
$sort = $_GET['sort'] ?? 'name';
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 20;
$contacts = $repo->search($userId, $term, $sort, $perPage, ($page - 1) * $perPage);
$total = $repo->countFor($userId);
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>My contacts</title></head>
<body>
<h1>My contacts (<?= (int) $total ?>)</h1>
<?php if ($flash !== null): ?>
<p class="success"><?= e($flash) ?></p>
<?php endif; ?>
<form method="get" action="">
<input type="search" name="q" value="<?= e($term) ?>" placeholder="Search name or email">
<button type="submit">Search</button>
</form>
<p><a href="/contact-add.php">Add a contact</a></p>
<?php if ($contacts === []): ?>
<p>No contacts found. <a href="/contact-add.php">Add your first one.</a></p>
<?php else: ?>
<table>
<thead>
<tr><th>Name</th><th>Email</th><th>Phone</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($contacts as $c): ?>
<tr>
<td><?= e($c['name']) ?></td>
<td><?= e($c['email'] ?? '') ?></td>
<td><?= e($c['phone'] ?? '') ?></td>
<td>
<a href="/contact-edit.php?id=<?= (int) $c['id'] ?>">Edit</a>
<form method="post" action="/contact-delete.php" style="display:inline"
onsubmit="return confirm('Delete this contact?')">
<input type="hidden" name="csrf" value="<?= e(csrfToken()) ?>">
<input type="hidden" name="id" value="<?= (int) $c['id'] ?>">
<button type="submit">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</body>
</html> - Delete is a POST form with a CSRF token, not a link. A
<a href="delete.php?id=5">can be triggered by anything that merely visits or prefetches the URL — a browser preloading links, a chat app generating a preview, a crawler following your page. The JavaScript confirmation is a courtesy for the user; the POST and the token are the actual protection.
