Project: Simple Blog
What You Will Build A small blog engine: a public home page listing the latest published posts with excerpts and pagination, a page for each post with a readable URL ( /post.php?slug=php-8-5-released
What You Will Build
A small blog engine: a public home page listing the latest published posts with excerpts and pagination, a page for each post with a readable URL (/post.php?slug=php-8-5-released), an RSS feed, and a protected editor where an author writes, saves drafts, and publishes posts. The project brings together PDO, text processing, safe HTML output, slugs, XML generation, and authentication.
Features
| Feature | Technique | Lesson |
|---|---|---|
| Readable, unique URLs | Slug generation with uniqueness check | Modify Strings, PHP RegEx |
| Drafts and scheduled publishing | status and published_at columns | MySQL Where |
| Safe post rendering | Escaping + paragraph formatting | XSS Prevention |
| Post excerpts | Multibyte-safe truncation at word boundaries | Multibyte Strings |
| RSS feed | XMLWriter | PHP XMLWriter |
| Author-only editor | Login, CSRF tokens | Project: Login & Registration System |
Project Structure
blog/
├── public/
│ ├── index.php ← latest posts
│ ├── post.php ← single post (?slug=…)
│ ├── feed.php ← RSS feed
│ └── admin/editor.php ← create/edit posts (login required)
└── src/
├── bootstrap.php ← session, e(), csrf, flash(), pdo(), paginate()
├── auth.php ← from the login project
├── text.php ← slugify(), excerpt(), renderBody()
└── PostRepository.php
Database Table
CREATE TABLE posts (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
author_id INT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
slug VARCHAR(220) NOT NULL,
body MEDIUMTEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
published_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_posts_slug (slug),
KEY idx_posts_status_published (status, published_at),
CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
A post is visible when status = 'published' and published_at is in the past — so posts can also be scheduled for the future. The composite index supports exactly this query.
Step 1: Text Helpers
<?php
// src/text.php
function slugify(string $title): string
{
$slug = mb_strtolower(trim($title));
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
$slug = trim($slug, '-');
return $slug !== '' ? mb_substr($slug, 0, 200) : 'post';
}
function excerpt(string $text, int $length = 160): string
{
$text = trim(preg_replace('/\s+/u', ' ', $text));
if (mb_strlen($text) <= $length) {
return $text;
}
$cut = mb_substr($text, 0, $length);
$lastSpace = mb_strrpos($cut, ' ');
if ($lastSpace !== false) {
$cut = mb_substr($cut, 0, $lastSpace);
}
return rtrim($cut, ' ,.;:') . '…';
}
function renderBody(string $text): string
{
$text = str_replace("\r\n", "\n", trim($text));
$paragraphs = preg_split('/\n{2,}/', $text);
return implode("\n", array_map(
fn(string $paragraph): string => '<p>' . nl2br(htmlspecialchars($paragraph, ENT_QUOTES, 'UTF-8')) . '</p>',
$paragraphs
));
}
Testing the Helpers
<?php
require __DIR__ . '/src/text.php';
echo slugify('PHP 8.5 Released: What’s New?') . "\n";
echo slugify(' Learning SimpleXML ') . "\n";
echo slugify('தமிழ் PHP') . "\n";
echo excerpt('PHP 8.5 brings the pipe operator, new array functions, and many improvements that make everyday code cleaner and safer.', 60) . "\n";
echo renderBody("First paragraph.\n\nSecond <b>line</b>\nwith a break.") . "\n";
Output
php-8-5-released-what-s-new
learning-simplexml
php
PHP 8.5 brings the pipe operator, new array functions, and…
<p>First paragraph.</p>
<p>Second <b>line</b><br />
with a break.</p>
How the Helpers Work
slugify()lowercases the title and replaces every run of characters other thana–zand0–9with a hyphen. The result is safe in URLs and readable. Non-Latin text (such as the Tamil word) is removed; for multilingual blogs, transliterate first with theintlextension (Transliterator::create('Any-Latin; Latin-ASCII')) or allow Unicode letters in slugs.excerpt()normalizes whitespace, cuts at the last complete word within the limit using multibyte-safe functions, removes trailing punctuation, and appends an ellipsis.renderBody()treats blank lines as paragraph breaks and single line breaks as<br />. Every paragraph is escaped first, so HTML typed into a post is displayed as text — authors cannot inject scripts.
For richer formatting (headings, links, lists), use a Markdown library such as league/commonmark configured with 'html_input' => 'strip' and 'allow_unsafe_links' => false.
Step 2: The Repository
<?php
// src/PostRepository.php
final class PostRepository
{
private const VISIBLE = "status = 'published' AND published_at <= NOW()";
public function __construct(private PDO $pdo) {}
public function countPublished(): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM posts WHERE ' . self::VISIBLE)->fetchColumn();
}
public function latestPublished(int $limit, int $offset = 0): array
{
$stmt = $this->pdo->prepare(
'SELECT p.title, p.slug, p.body, p.published_at, u.name AS author
FROM posts p JOIN users u ON u.id = p.author_id
WHERE ' . self::VISIBLE . '
ORDER BY p.published_at DESC, p.id DESC
LIMIT :limit OFFSET :offset'
);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
public function findPublishedBySlug(string $slug): ?array
{
$stmt = $this->pdo->prepare(
'SELECT p.title, p.slug, p.body, p.published_at, u.name AS author
FROM posts p JOIN users u ON u.id = p.author_id
WHERE p.slug = ? AND ' . self::VISIBLE
);
$stmt->execute([$slug]);
return $stmt->fetch() ?: null;
}
public function save(?int $id, int $authorId, string $title, string $body, string $status): int
{
if ($id === null) {
$this->pdo->prepare(
'INSERT INTO posts (author_id, title, slug, body, status, published_at)
VALUES (?, ?, ?, ?, ?, IF(? = \'published\', NOW(), NULL))'
)->execute([$authorId, $title, $this->uniqueSlug(slugify($title)), $body, $status, $status]);
return (int) $this->pdo->lastInsertId();
}
$this->pdo->prepare(
'UPDATE posts
SET title = ?, body = ?, status = ?,
published_at = IF(? = \'published\', COALESCE(published_at, NOW()), published_at)
WHERE id = ? AND author_id = ?'
)->execute([$title, $body, $status, $status, $id, $authorId]);
return $id;
}
private function uniqueSlug(string $base): string
{
$stmt = $this->pdo->prepare('SELECT slug FROM posts WHERE slug = ? OR slug LIKE ?');
$stmt->execute([$base, addcslashes($base, '%_\\') . '-%']);
$taken = $stmt->fetchAll(PDO::FETCH_COLUMN);
$slug = $base;
for ($n = 2; in_array($slug, $taken, true); $n++) {
$slug = "$base-$n";
}
return $slug;
}
}
Design Notes
- The visibility rule is defined once (
VISIBLE) and reused, so drafts or scheduled posts can never leak through one forgotten query. uniqueSlug()appends-2,-3, … when a slug already exists. TheUNIQUEindex remains the final guarantee if two posts with the same title are saved at the same moment.- Slugs are generated only when a post is created; editing the title later keeps the URL stable, so existing links and search-engine entries continue to work.
published_atis set when a post is first published and preserved on later edits.- Updates are restricted to the post's author (
AND author_id = ?).
Step 3: Public Pages
public/index.php
<?php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/text.php';
require __DIR__ . '/../src/PostRepository.php';
$posts = new PostRepository(pdo());
$p = paginate($posts->countPublished(), 10, filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT) ?: 1);
?>
<h1>GoCourse Blog</h1>
<?php foreach ($posts->latestPublished($p['per_page'], $p['offset']) as $post): ?>
<article>
<h2><a href="/post.php?slug=<?= e(rawurlencode($post['slug'])) ?>"><?= e($post['title']) ?></a></h2>
<p class="meta">
<?= e((new DateTimeImmutable($post['published_at']))->format('d M Y')) ?> · <?= e($post['author']) ?>
</p>
<p><?= e(excerpt($post['body'])) ?></p>
</article>
<?php endforeach; ?>
<?= paginationLinks($p) ?>
public/post.php
<?php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/text.php';
require __DIR__ . '/../src/PostRepository.php';
$slug = is_string($_GET['slug'] ?? null) ? $_GET['slug'] : '';
$post = preg_match('/^[a-z0-9-]{1,220}$/', $slug) ? (new PostRepository(pdo()))->findPublishedBySlug($slug) : null;
if ($post === null) {
http_response_code(404);
exit('Post not found.');
}
?>
<article>
<h1><?= e($post['title']) ?></h1>
<p class="meta"><?= e((new DateTimeImmutable($post['published_at']))->format('d F Y')) ?> · <?= e($post['author']) ?></p>
<?= renderBody($post['body']) ?>
</article>
renderBody() already escapes its content, so its output is printed directly — this is the only place where generated HTML is output without e(), and it is safe because the function escapes internally. Invalid or unknown slugs return a real 404 status, which matters for search engines.
Step 4: The RSS Feed
<?php
// public/feed.php
require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/text.php';
require __DIR__ . '/../src/PostRepository.php';
header('Content-Type: application/rss+xml; charset=utf-8');
$baseUrl = 'https://gocourse.in/blog';
$writer = new XMLWriter();
$writer->openUri('php://output');
$writer->setIndent(true);
$writer->startDocument('1.0', 'UTF-8');
$writer->startElement('rss');
$writer->writeAttribute('version', '2.0');
$writer->startElement('channel');
$writer->writeElement('title', 'GoCourse Blog');
$writer->writeElement('link', $baseUrl);
$writer->writeElement('description', 'Tutorials and news from GoCourse');
foreach ((new PostRepository(pdo()))->latestPublished(20) as $post) {
$url = "$baseUrl/post.php?slug=" . rawurlencode($post['slug']);
$writer->startElement('item');
$writer->writeElement('title', $post['title']);
$writer->writeElement('link', $url);
$writer->writeElement('guid', $url);
$writer->writeElement('pubDate', (new DateTimeImmutable($post['published_at']))->format(DATE_RSS));
$writer->writeElement('description', excerpt($post['body'], 300));
$writer->endElement();
}
$writer->endElement(); // channel
$writer->endElement(); // rss
$writer->endDocument();
$writer->flush();
XMLWriter escapes titles and excerpts automatically; dates use the RSS format (DATE_RSS). Add <link rel="alternate" type="application/rss+xml" href="/feed.php"> to the page <head> so feed readers discover it.
Step 5: The Editor (Essentials)
<?php
// public/admin/editor.php
require __DIR__ . '/../../src/bootstrap.php';
require __DIR__ . '/../../src/auth.php';
require __DIR__ . '/../../src/text.php';
require __DIR__ . '/../../src/PostRepository.php';
$user = requireLogin(pdo());
$posts = new PostRepository(pdo());
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT) ?: null;
$title = '';
$body = '';
$errors = [];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$title = trim((string) ($_POST['title'] ?? ''));
$body = trim((string) ($_POST['body'] ?? ''));
$status = ($_POST['status'] ?? '') === 'published' ? 'published' : 'draft';
if (!csrfValid($_POST['_csrf'] ?? null)) {
$errors[] = 'Your session expired. Please try again.';
}
if (mb_strlen($title) < 3 || mb_strlen($title) > 200) {
$errors[] = 'The title must be 3–200 characters long.';
}
if (mb_strlen($body) < 20) {
$errors[] = 'Please write at least 20 characters.';
}
if ($errors === []) {
$savedId = $posts->save($id, (int) $user['id'], $title, $body, $status);
flash($status === 'published' ? 'The post is published.' : 'Draft saved.');
header("Location: /admin/editor.php?id=$savedId", true, 303);
exit;
}
}
// ... render a form with title, a textarea for the body, a status select (draft/published),
// the CSRF field, and the error list — every value escaped with e().
When editing an existing post, load its current title and body (restricted to the logged-in author) before showing the form.
Testing the Project
| Test | Expected Result |
|---|---|
| Publish two posts with the same title | Slugs my-title and my-title-2 |
| Save a draft | Not on the home page, post.php returns 404 |
Publish a post with <script> in the body | Shown as text on the post page |
Open post.php?slug=../../etc | 404 (slug pattern check) |
| Edit a published post's title | The URL (slug) stays the same; published_at unchanged |
Open feed.php | Valid RSS with the latest 20 posts |
| Open the editor without logging in | Redirect to the login page |
Common Mistakes
Rendering Post Bodies as Raw HTML
Effect: Stored XSS through posts (or a compromised author account).
Fix: Escape, then format — or use a Markdown library with raw HTML disabled.
Changing Slugs When Titles Change
Effect: Broken links and lost search rankings.
Fix: Keep slugs stable (or add redirects from old slugs).
Repeating Visibility Conditions in Every Query
Effect: Drafts leak through a query that forgot the condition.
Fix: Centralize the rule in the repository.
Cutting Excerpts With substr()
Effect: Broken multibyte characters and half words.
Fix: Use mb_ functions and cut at word boundaries.
Best Practices
- Generate stable, unique, URL-safe slugs and enforce uniqueness in the database.
- Escape all user-written content; format it only after escaping.
- Keep publishing rules (status and date) in one place in the data layer.
- Restrict editing to authenticated authors and protect forms with CSRF tokens.
- Offer an RSS feed generated with XMLWriter and return proper 404s for missing posts.
Official reference: https://www.php.net/manual/en/book.pdo.php