This capstone combines every Intermediate chapter into one small but genuinely structured blog: a login system, a Post class backed by a real database, full CRUD, and proper input/output handling — the shape of a real (if minimal) PHP application.
Ch 1-2
OOP — Post, User classes
Ch 3
Exceptions for DB errors
Ch 4
PDO + prepared statements
Ch 5
Sessions, password_hash
Ch 6
require_once / autoload pattern
Ch 8
htmlspecialchars, filter_var
Ch 9
Regex slug generation
Project Structure
blog/
schema.sql
db.php
Post.php
exceptions/
PostNotFoundException.php
login.php
logout.php
index.php
new_post.php
edit_post.php
delete_post.php
schema.sql — The Database Structure
schema.sql
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL
);
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
db.php — The Shared Connection
db.php
<?php
try {
$pdo = new PDO("mysql:host=localhost;dbname=blog_db;charset=utf8mb4", "db_user", "secret_password");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
} catch (PDOException $e) {
die("Database connection failed.");
}
?>
Post.php — An OOP Wrapper Around CRUD
Post.php
<?php
class PostNotFoundException extends Exception {}
class Post {
public function __construct(private PDO $pdo) {}
private function slugify(string $title): string {
$slug = strtolower($title);
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
return trim($slug, '-');
}
public function create(string $title, string $body): int {
$stmt = $this->pdo->prepare("INSERT INTO posts (title, slug, body) VALUES (:title, :slug, :body)");
$stmt->execute([
'title' => $title,
'slug' => $this->slugify($title),
'body' => $body
]);
return (int)$this->pdo->lastInsertId();
}
public function find(int $id): array {
$stmt = $this->pdo->prepare("SELECT * FROM posts WHERE id = :id");
$stmt->execute(['id' => $id]);
$post = $stmt->fetch();
if (!$post) {
throw new PostNotFoundException("No post found with ID $id");
}
return $post;
}
public function all(): array {
return $this->pdo->query("SELECT * FROM posts ORDER BY created_at DESC")->fetchAll();
}
public function update(int $id, string $title, string $body) {
$stmt = $this->pdo->prepare("UPDATE posts SET title = :title, body = :body WHERE id = :id");
$stmt->execute(['title' => $title, 'body' => $body, 'id' => $id]);
}
public function delete(int $id) {
$stmt = $this->pdo->prepare("DELETE FROM posts WHERE id = :id");
$stmt->execute(['id' => $id]);
}
}
?>
Every database detail (the SQL, the placeholders, the fetch mode) lives inside Post — the rest of the application calls $post->create(...), $post->find($id), etc., without needing to know or repeat any SQL. find() throws a custom PostNotFoundException (Chapter 3's pattern) rather than silently returning something misleading like false.
new_post.php — A Protected Create Page
new_post.php
<?php
session_start();
require __DIR__ . '/db.php';
require __DIR__ . '/Post.php';
if (!($_SESSION['loggedIn'] ?? false)) {
header("Location: login.php");
exit;
}
$post = new Post($pdo);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$body = trim($_POST['body'] ?? '');
if ($title === '' || $body === '') {
$error = "Title and body are both required.";
} else {
$newId = $post->create($title, $body);
header("Location: index.php");
exit;
}
}
?>
index.php — Listing Posts Safely
index.php
<?php
require __DIR__ . '/db.php';
require __DIR__ . '/Post.php';
$post = new Post($pdo);
$allPosts = $post->all();
?>
<?php foreach ($allPosts as $row): ?>
<h2><?= htmlspecialchars($row['title']) ?></h2>
<p><?= htmlspecialchars($row['body']) ?></p>
<?php endforeach; ?>
Every echoed value from the database is wrapped in htmlspecialchars() — exactly Chapter 8's rule — since post titles and bodies are, ultimately, also a form of user input that was stored and is now being redisplayed.
Deliberate simplifications, for a capstone of this scope
This project skips a few things a genuinely production blog would need: CSRF protection on the forms, pagination for index.php, and authorisation checks confirming a post's author matches the logged-in user before allowing edit/delete. These are natural "what's missing" discussion points, not oversights — the goal here is demonstrating Chapters 1-9 working together cleanly, not shipping a complete product.
Coding Challenge — Extend the Capstone
Final Challenge
Add a findBySlug(string $slug): array method to the Post class, used to support pretty URLs like post.php?slug=my-first-post instead of an ID. It should throw PostNotFoundException if no matching post exists, exactly like find(). Then write post.php, which reads $_GET['slug'], calls the new method inside a try/catch, and either displays the post (escaped with htmlspecialchars) or a friendly "Post not found" message.
📄 View solution
Course 2 Complete — PHP Intermediate
- Chapters 1-9 covered: OOP basics, inheritance/interfaces/traits, exceptions, PDO/CRUD, sessions/login, namespaces/Composer, JSON/cURL, file uploads/safe input, regex
- This capstone combined all nine into a small, genuinely structured blog application with login-protected CRUD
- Known, deliberate gaps: no CSRF protection, pagination, or post-ownership authorisation — flagged as discussion points, not oversights
- Next: PHP Advanced (Course 3) — Composer in depth, design patterns, MVC architecture, testing, security in depth, and a REST API capstone