Overview
A blog CMS is the natural place to combine PHP's object-oriented syntax with real database access, because "create, read, update, delete a post" is exactly the kind of related behavior a class is meant to group together. Instead of writing `SELECT`, `INSERT`, `UPDATE`, and `DELETE` queries directly inside `index.php`, `create.php`, and `edit.php` — and inevitably writing slightly different versions of the same query more than once — this project wraps every database interaction behind a single `Post` class with one clearly named method per operation.
By the end of this tutorial you will have `Post.php` (the class), `index.php` (lists every post), `create.php` and `edit.php` (a shared form pattern for writing and changing a post), and `delete.php` (a POST-only deletion endpoint), all sharing one PDO connection from `db.php`. Every single value that comes from a visitor — a post id in the URL, a title or body typed into a form — reaches the database exclusively through a prepared statement's bound parameters, never concatenated directly into a SQL string, which is what makes SQL injection impossible against this code no matter what a visitor types.
- A `posts` MySQL table storing a title, body content, and created/updated timestamps.
- A `Post` class wrapping every database interaction behind clearly named methods.
- Prepared statements everywhere visitor input reaches SQL, so injection is not possible.
- An `index.php` page listing every post with edit and delete links.
- Create and edit forms that share the same layout and validate input before saving.
- A delete action guarded by a confirmation prompt and executed through the class, not raw SQL on the page.
Prerequisites
- OOP basics — defining a class with properties, a constructor, and methods.
- PDO and prepared statements — binding parameters with `?` instead of building SQL strings by hand.
- MySQL basics — `SELECT`/`INSERT`/`UPDATE`/`DELETE` and an auto-increment primary key.
- Forms and superglobals — reading `$_POST` for form submissions and `$_GET` for a post id in the URL.
- Functions and includes — organizing a small multi-page project across several files with `require_once`.
Project Structure
The database connection lives in `db.php`, using the same PDO-with-exceptions pattern as the Login System project. `Post.php` defines the `Post` class, which receives that shared `$pdo` connection through its constructor rather than opening a second connection of its own — a small but important form of dependency injection that means every page in the project talks to the database through the exact same connection object. `index.php`, `create.php`, `edit.php`, and `delete.php` each handle exactly one page or action, and every one of them starts by requiring `db.php` and `Post.php`, then creating one `new Post($pdo)` to work with.
Step 1: Design the Posts Table
`content` is deliberately typed as `TEXT` rather than `VARCHAR`, since `VARCHAR` in MySQL has to declare a maximum length up front and a blog post can easily run past any reasonable limit you would pick. The `updated_at` column's `ON UPDATE CURRENT_TIMESTAMP` clause is a MySQL feature that refreshes that column automatically on every `UPDATE`, with no PHP code needed to set it manually each time a post is edited.
CREATE TABLE posts ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, -- TEXT, not VARCHAR: a post can run far past 255 characters created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- MySQL refreshes this automatically on every UPDATE);Step 2: Build the Post Class
The constructor takes the shared `$pdo` connection as a parameter instead of creating its own — this is dependency injection, and it means `Post` never has to know a hostname, username, or password, only how to use a connection it is handed. `create()` is the first method: title and content are bound through a prepared statement's `?` placeholders rather than being woven into the query string, and `lastInsertId()` hands back the new row's auto-generated id, which callers can use to redirect straight to the new post.
<?phpclass Post { private PDO $pdo; // Every method reaches the database through this one shared connection
public function __construct(PDO $pdo) { // Dependency injection: the class receives its PDO connection instead of opening // its own, so every page in the project shares the single connection from db.php. $this->pdo = $pdo; }
public function create(string $title, string $content): int { // Prepared statement: title/content are bound as data, never concatenated into // the SQL string, which is exactly what makes this immune to SQL injection. $stmt = $this->pdo->prepare('INSERT INTO posts (title, content) VALUES (?, ?)'); $stmt->execute([$title, $content]); return (int) $this->pdo->lastInsertId(); // Hand back the new post's id, e.g. to redirect straight to it }}Step 3: Read a Single Post and List All Posts
`find()` returns `?array` — a nullable array — because a lookup by id can legitimately fail if the post was deleted or the id in the URL was simply wrong; returning `null` gives callers something explicit to check with `=== null`, rather than having to remember that `PDOStatement::fetch()` itself returns `false` (not `null`) on no match. `all()` orders by `created_at DESC` so the newest writing always appears first, matching how every real blog's homepage behaves. Add both methods inside the `Post` class from Step 2, alongside `create()`.
public function find(int $id): ?array { $stmt = $this->pdo->prepare('SELECT * FROM posts WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(); return $row === false ? null : $row; // fetch() returns false on no match; null reads more clearly to callers}
public function all(): array { // Newest posts first, so a blog reader sees the most recent writing at the top of the list. $stmt = $this->pdo->query('SELECT * FROM posts ORDER BY created_at DESC'); return $stmt->fetchAll();}Step 4: Update and Delete Posts
Both methods return a `bool` built from `rowCount()`, which reports how many rows the last statement actually affected — `0` if no row with that id existed, so callers can distinguish "the edit worked" from "there was nothing to edit" without a second query. Add both methods inside the `Post` class as well.
public function update(int $id, string $title, string $content): bool { $stmt = $this->pdo->prepare('UPDATE posts SET title = ?, content = ? WHERE id = ?'); $stmt->execute([$title, $content, $id]); return $stmt->rowCount() > 0; // rowCount() is 0 if no row matched that id, or nothing actually changed}
public function delete(int $id): bool { $stmt = $this->pdo->prepare('DELETE FROM posts WHERE id = ?'); $stmt->execute([$id]); return $stmt->rowCount() > 0;}Step 5: Build the Post List Page
`index.php` calls `$post->all()` and loops the results with `foreach`, truncating each post's body to a short preview with `substr()` so the list stays scannable. The delete link is deliberately a small `<form>` that submits a `POST` request rather than a plain `<a href="delete.php?id=...">`, because a `GET` link can be triggered accidentally — by a browser prefetching links, or even by a malicious `<img>` tag embedded on another page — while a `POST` only fires in response to an actual form submission, which the `confirm()` popup in `onclick` gives the visitor one last chance to cancel.
<?phprequire_once 'db.php';require_once 'Post.php';
$post = new Post($pdo);$posts = $post->all();?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Blog</title></head><body> <h1>Blog Posts</h1> <a href="create.php">+ New Post</a>
<?php foreach ($posts as $row): ?> <article> <h2><?= htmlspecialchars($row['title']) ?></h2> <p><?= nl2br(htmlspecialchars(substr($row['content'], 0, 200))) ?>...</p> <a href="edit.php?id=<?= (int) $row['id'] ?>">Edit</a> <form action="delete.php" method="POST" style="display:inline"> <input type="hidden" name="id" value="<?= (int) $row['id'] ?>"> <button type="submit" onclick="return confirm('Delete this post?')">Delete</button> </form> </article> <?php endforeach; ?></body></html>Step 6: Build the Create and Edit Forms
`create.php` and `edit.php` share nearly identical bodies — read `$_POST` on submit, validate, save, redirect — with one meaningful difference: `edit.php` first loads the existing post with `find()` using the id from `$_GET`, and uses its title and content to pre-fill the form on the first (non-POST) visit. `delete.php` has no form of its own at all; it only ever accepts the `POST` request sent by the delete button built in Step 5, deletes that post through the class, and redirects straight back to the list.
<?phprequire_once 'db.php';require_once 'Post.php';
$errors = [];$title = $content = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $title = trim($_POST['title'] ?? ''); $content = trim($_POST['content'] ?? '');
if ($title === '' || $content === '') { $errors[] = 'Both title and content are required.'; } else { $post = new Post($pdo); $post->create($title, $content); header('Location: index.php'); // Back to the list, where the new post now appears first exit; }}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>New Post</title></head><body> <h1>New Post</h1>
<?php foreach ($errors as $error): ?> <p style="color: red;"><?= htmlspecialchars($error) ?></p> <?php endforeach; ?>
<form action="" method="POST"> <label for="title">Title</label><br> <input type="text" id="title" name="title" value="<?= htmlspecialchars($title) ?>"><br>
<label for="content">Content</label><br> <textarea id="content" name="content" rows="10"><?= htmlspecialchars($content) ?></textarea><br>
<button type="submit">Publish</button> </form></body></html><?phprequire_once 'db.php';require_once 'Post.php';
$post = new Post($pdo);$id = (int) ($_GET['id'] ?? 0);$existing = $post->find($id);
if ($existing === null) { http_response_code(404); die('Post not found.');}
$errors = [];$title = $existing['title'];$content = $existing['content'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $title = trim($_POST['title'] ?? ''); $content = trim($_POST['content'] ?? '');
if ($title === '' || $content === '') { $errors[] = 'Both title and content are required.'; } else { $post->update($id, $title, $content); header('Location: index.php'); exit; }}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Edit Post</title></head><body> <h1>Edit Post</h1>
<?php foreach ($errors as $error): ?> <p style="color: red;"><?= htmlspecialchars($error) ?></p> <?php endforeach; ?>
<form action="" method="POST"> <label for="title">Title</label><br> <input type="text" id="title" name="title" value="<?= htmlspecialchars($title) ?>"><br>
<label for="content">Content</label><br> <textarea id="content" name="content" rows="10"><?= htmlspecialchars($content) ?></textarea><br>
<button type="submit">Save Changes</button> </form></body></html><?phprequire_once 'db.php';require_once 'Post.php';
// POST-only on purpose: a GET-triggered delete link could be followed accidentally// (or triggered by something like an <img> tag on another page), so deletion only// ever happens in response to the confirmed form submit built in Step 5.if ($_SERVER['REQUEST_METHOD'] === 'POST') { $id = (int) ($_POST['id'] ?? 0); $post = new Post($pdo); $post->delete($id);}
header('Location: index.php');exit;Complete Code
Here is the full `Post` class with every method from Steps 2-4 assembled together, plus the four page scripts that use it.
<?phpclass Post { private PDO $pdo;
public function __construct(PDO $pdo) { $this->pdo = $pdo; }
public function create(string $title, string $content): int { $stmt = $this->pdo->prepare('INSERT INTO posts (title, content) VALUES (?, ?)'); $stmt->execute([$title, $content]); return (int) $this->pdo->lastInsertId(); }
public function find(int $id): ?array { $stmt = $this->pdo->prepare('SELECT * FROM posts WHERE id = ?'); $stmt->execute([$id]); $row = $stmt->fetch(); return $row === false ? null : $row; }
public function all(): array { $stmt = $this->pdo->query('SELECT * FROM posts ORDER BY created_at DESC'); return $stmt->fetchAll(); }
public function update(int $id, string $title, string $content): bool { $stmt = $this->pdo->prepare('UPDATE posts SET title = ?, content = ? WHERE id = ?'); $stmt->execute([$title, $content, $id]); return $stmt->rowCount() > 0; }
public function delete(int $id): bool { $stmt = $this->pdo->prepare('DELETE FROM posts WHERE id = ?'); $stmt->execute([$id]); return $stmt->rowCount() > 0; }}<?phprequire_once 'db.php';require_once 'Post.php';
$post = new Post($pdo);$posts = $post->all();?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Blog</title></head><body> <h1>Blog Posts</h1> <a href="create.php">+ New Post</a>
<?php foreach ($posts as $row): ?> <article> <h2><?= htmlspecialchars($row['title']) ?></h2> <p><?= nl2br(htmlspecialchars(substr($row['content'], 0, 200))) ?>...</p> <a href="edit.php?id=<?= (int) $row['id'] ?>">Edit</a> <form action="delete.php" method="POST" style="display:inline"> <input type="hidden" name="id" value="<?= (int) $row['id'] ?>"> <button type="submit" onclick="return confirm('Delete this post?')">Delete</button> </form> </article> <?php endforeach; ?></body></html><?phprequire_once 'db.php';require_once 'Post.php';
$errors = [];$title = $content = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $title = trim($_POST['title'] ?? ''); $content = trim($_POST['content'] ?? '');
if ($title === '' || $content === '') { $errors[] = 'Both title and content are required.'; } else { $post = new Post($pdo); $post->create($title, $content); header('Location: index.php'); exit; }}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>New Post</title></head><body> <h1>New Post</h1>
<?php foreach ($errors as $error): ?> <p style="color: red;"><?= htmlspecialchars($error) ?></p> <?php endforeach; ?>
<form action="" method="POST"> <label for="title">Title</label><br> <input type="text" id="title" name="title" value="<?= htmlspecialchars($title) ?>"><br>
<label for="content">Content</label><br> <textarea id="content" name="content" rows="10"><?= htmlspecialchars($content) ?></textarea><br>
<button type="submit">Publish</button> </form></body></html><?phprequire_once 'db.php';require_once 'Post.php';
$post = new Post($pdo);$id = (int) ($_GET['id'] ?? 0);$existing = $post->find($id);
if ($existing === null) { http_response_code(404); die('Post not found.');}
$errors = [];$title = $existing['title'];$content = $existing['content'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $title = trim($_POST['title'] ?? ''); $content = trim($_POST['content'] ?? '');
if ($title === '' || $content === '') { $errors[] = 'Both title and content are required.'; } else { $post->update($id, $title, $content); header('Location: index.php'); exit; }}?><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Edit Post</title></head><body> <h1>Edit Post</h1>
<?php foreach ($errors as $error): ?> <p style="color: red;"><?= htmlspecialchars($error) ?></p> <?php endforeach; ?>
<form action="" method="POST"> <label for="title">Title</label><br> <input type="text" id="title" name="title" value="<?= htmlspecialchars($title) ?>"><br>
<label for="content">Content</label><br> <textarea id="content" name="content" rows="10"><?= htmlspecialchars($content) ?></textarea><br>
<button type="submit">Save Changes</button> </form></body></html><?phprequire_once 'db.php';require_once 'Post.php';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $id = (int) ($_POST['id'] ?? 0); $post = new Post($pdo); $post->delete($id);}
header('Location: index.php');exit;Sample Run
Click Run to see what this code prints.
Extend This Project
- Add pagination to `index.php` (e.g. `LIMIT`/`OFFSET`) once the posts table grows past a single screenful.
- Add a `slug` column and rewrite URLs to `/blog/my-post-title` instead of `edit.php?id=...`.
- Add a `published` boolean column so drafts can be saved without appearing on the public list.
- Add simple category or tag support with a second table and a join query in `Post::all()`.
- Store the currently logged-in author's id on each post (combine with the Login System project) so multiple authors can share one CMS.
Summary
You built a small content management system the way real PHP applications structure database access: every `SELECT`, `INSERT`, `UPDATE`, and `DELETE` lives inside one `Post` class behind a clearly named method, and every value that comes from a visitor reaches SQL only through a prepared statement's bound parameters. That combination — a class owning its own data access, plus prepared statements everywhere user input meets a query — is the foundation almost every larger PHP application, whether hand-rolled or framework-based, builds on top of.