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
postsMySQL table storing a title, body content, and created/updated timestamps. - A
Postclass wrapping every database interaction behind clearly named methods. - Prepared statements everywhere visitor input reaches SQL, so injection is not possible.
- An
index.phppage 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/DELETEand an auto-increment primary key. - Forms and superglobals — reading
$_POSTfor form submissions and$_GETfor 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
slugcolumn and rewrite URLs to/blog/my-post-titleinstead ofedit.php?id=.... - Add a
publishedboolean 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.