LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
PHP ProgrammingIntermediate~2.5 hours

Blog CMS

Create, edit, and display blog posts stored in a database.

OOPPHP with MySQLForms

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.

What You'll Build
  • 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.

<?php
class 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.

index.php
<?php
require_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.

create.php
<?php
require_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>
edit.php
<?php
require_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>
delete.php
<?php
require_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.

Post.php
<?php
class 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;
}
}
index.php
<?php
require_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>
create.php
<?php
require_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>
edit.php
<?php
require_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>
delete.php
<?php
require_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

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.