Project: Simple Blog

JA JASWANT Updated 19 Oct 2026
9 min read ·Lesson 121 of 143
In this post

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

FeatureTechniqueLesson
Readable, unique URLsSlug generation with uniqueness checkModify Strings, PHP RegEx
Drafts and scheduled publishingstatus and published_at columnsMySQL Where
Safe post renderingEscaping + paragraph formattingXSS Prevention
Post excerptsMultibyte-safe truncation at word boundariesMultibyte Strings
RSS feedXMLWriterPHP XMLWriter
Author-only editorLogin, CSRF tokensProject: 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

  1. slugify() lowercases the title and replaces every run of characters other than a–z and 0–9 with 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 the intl extension (Transliterator::create('Any-Latin; Latin-ASCII')) or allow Unicode letters in slugs.
  2. excerpt() normalizes whitespace, cuts at the last complete word within the limit using multibyte-safe functions, removes trailing punctuation, and appends an ellipsis.
  3. 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. The UNIQUE index 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_at is 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

TestExpected Result
Publish two posts with the same titleSlugs my-title and my-title-2
Save a draftNot on the home page, post.php returns 404
Publish a post with <script> in the bodyShown as text on the post page
Open post.php?slug=../../etc404 (slug pattern check)
Edit a published post's titleThe URL (slug) stays the same; published_at unchanged
Open feed.phpValid RSS with the latest 20 posts
Open the editor without logging inRedirect 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

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning
Ask about this post
AI Ask about this post

Ask questions about Project: Simple Blog and get answers drawn from it.

Signed-in readers only.