LearnAI ToolsCareerPractice BuildsPlayContact
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.