Skip to content

PHP Project — Build a Complete Blog System from Scratch

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about PHP Project. We cover key concepts, practical examples, and best practices to help you master this topic.

This PHP project guides you through building a complete blog system with post creation and editing, category and tag management, a comment system with moderation, pagination, search functionality, and an admin dashboard with authentication. You will apply MVC patterns, PDO database access, session-based authentication, and PHP 8 features in a real-world application.

What You'll Learn

  • Designing a normalized MySQL schema for posts, categories, tags, and comments
  • Implementing CRUD operations for blog posts with PDO
  • Building a user authentication system with password hashing
  • Creating an admin dashboard with recent posts and moderation tools
  • Adding pagination, search, and category filtering for the public view
  • Implementing comment submission, moderation, and spam detection
  • Using URL slugs for SEO-friendly post URLs

Why It Matters

A blog system is the canonical CRUD application that demonstrates every fundamental web development skill: database design, user authentication, content management, search, pagination, and security. Mastering these patterns prepares you to build any data-driven web application, from e-commerce stores to content management systems.

Real-World Use

The DodaTech tutorials site runs on a custom PHP blog engine similar to this project. It handles 15,000+ pages with paginated listings, category-based filtering, comment moderation, and an admin dashboard that manages content across five programming languages.

Learning Path

flowchart LR
  A[Contact Form Project] --> B[Blog System\nYou are here]
  B --> C[REST API Project]
  style B fill:#f90,color:#fff

Database Schema

CREATE TABLE categories (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100) NOT NULL UNIQUE,
  slug VARCHAR(120) NOT NULL UNIQUE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  slug VARCHAR(280) NOT NULL UNIQUE,
  content LONGTEXT NOT NULL,
  excerpt VARCHAR(500) NOT NULL,
  category_id INT NOT NULL,
  user_id INT NOT NULL,
  status ENUM('draft', 'published') DEFAULT 'draft',
  published_at TIMESTAMP NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  FOREIGN KEY (category_id) REFERENCES categories(id),
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE tags (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(50) NOT NULL UNIQUE,
  slug VARCHAR(60) NOT NULL UNIQUE
);

CREATE TABLE post_tags (
  post_id INT NOT NULL,
  tag_id INT NOT NULL,
  PRIMARY KEY (post_id, tag_id),
  FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
  FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);

CREATE TABLE users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(50) NOT NULL UNIQUE,
  email VARCHAR(255) NOT NULL UNIQUE,
  password_hash VARCHAR(255) NOT NULL,
  role ENUM('admin', 'editor') DEFAULT 'editor',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE comments (
  id INT AUTO_INCREMENT PRIMARY KEY,
  post_id INT NOT NULL,
  author_name VARCHAR(100) NOT NULL,
  author_email VARCHAR(255) NOT NULL,
  content TEXT NOT NULL,
  status ENUM('pending', 'approved', 'spam') DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
);

Step 1: Configuration and Database Connection

Create config.php:

<?php
define('DB_HOST', 'localhost');
define('DB_NAME', 'blog');
define('DB_USER', 'root');
define('DB_PASS', '');
define('POSTS_PER_PAGE', 10);
define('SITE_NAME', 'My Blog');

function db(): PDO
{
  static $pdo = null;
  if ($pdo === null) {
    $pdo = new PDO(
      "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4",
      DB_USER,
      DB_PASS,
      [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
      ]
    );
  }
  return $pdo;
}

function slugify(string $text): string
{
  $text = preg_replace('/[^\p{L}\p{N}\s-]/u', '', mb_strtolower($text));
  $text = preg_replace('/[\s-]+/', '-', trim($text));
  return trim($text, '-');
}

Step 2: User Authentication

Create auth.php:

<?php
function login(string $username, string $password): bool
{
  $pdo = db();
  $stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username');
  $stmt->execute(['username' => $username]);
  $user = $stmt->fetch();

  if ($user && password_verify($password, $user['password_hash'])) {
    $_SESSION['user_id'] = $user['id'];
    $_SESSION['username'] = $user['username'];
    $_SESSION['role'] = $user['role'];
    session_regenerate_id(true);
    return true;
  }
  return false;
}

function isLoggedIn(): bool
{
  return isset($_SESSION['user_id']);
}

function requireLogin(): void
{
  if (!isLoggedIn()) {
    header('Location: /login.php');
    exit;
  }
}

function logout(): void
{
  session_destroy();
  header('Location: /login.php');
  exit;
}

Step 3: Post Listing with Pagination

Create index.php:

<?php
require_once 'config.php';

$page = max(1, (int)($_GET['page'] ?? 1));
$category = $_GET['category'] ?? null;
$search = $_GET['search'] ?? null;
$offset = ($page - 1) * POSTS_PER_PAGE;

$pdo = db();
$where = ['p.status = :status'];
$params = ['status' => 'published'];

if ($category) {
  $where[] = 'c.slug = :category';
  $params['category'] = $category;
}

if ($search) {
  $where[] = '(p.title LIKE :search OR p.content LIKE :search2)';
  $params['search'] = "%$search%";
  $params['search2'] = "%$search%";
}

$whereClause = implode(' AND ', $where);

$countStmt = $pdo->prepare("SELECT COUNT(*) FROM posts p JOIN categories c ON p.category_id = c.id WHERE $whereClause");
$countStmt->execute($params);
$totalPosts = $countStmt->fetchColumn();
$totalPages = max(1, ceil($totalPosts / POSTS_PER_PAGE));

$stmt = $pdo->prepare("
  SELECT p.id, p.title, p.slug, p.excerpt, p.published_at,
         c.name as category_name, c.slug as category_slug,
         u.username
  FROM posts p
  JOIN categories c ON p.category_id = c.id
  JOIN users u ON p.user_id = u.id
  WHERE $whereClause
  ORDER BY p.published_at DESC
  LIMIT :limit OFFSET :offset
");
$stmt->bindValue('limit', POSTS_PER_PAGE, PDO::PARAM_INT);
$stmt->bindValue('offset', $offset, PDO::PARAM_INT);
foreach ($params as $key => $val) {
  $stmt->bindValue(":$key", $val);
}
$stmt->execute();
$posts = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html>
<head><title><?= SITE_NAME ?></title></head>
<body>
  <h1><?= SITE_NAME ?></h1>

  <form method="GET">
    <input type="text" name="search" placeholder="Search posts..." value="<?= htmlspecialchars($search ?? '') ?>">
    <button type="submit">Search</button>
  </form>

  <?php foreach ($posts as $post): ?>
    <article>
      <h2><a href="/post.php?slug=<?= urlencode($post['slug']) ?>"><?= htmlspecialchars($post['title']) ?></a></h2>
      <p class="meta">By <?= htmlspecialchars($post['username']) ?> in
         <a href="/?category=<?= urlencode($post['category_slug']) ?>"><?= htmlspecialchars($post['category_name']) ?></a>
         on <?= $post['published_at'] ?></p>
      <p><?= htmlspecialchars($post['excerpt']) ?></p>
    </article>
  <?php endforeach; ?>

  <div class="pagination">
    <?php for ($i = 1; $i <= $totalPages; $i++): ?>
      <a href="/?page=<?= $i ?><?= $category ? '&category=' . urlencode($category) : '' ?>"><?= $i ?></a>
    <?php endfor; ?>
  </div>
</body>
</html>

Step 4: Single Post View with Comments

Create post.php:

<?php
require_once 'config.php';

$slug = $_GET['slug'] ?? '';
$pdo = db();

$stmt = $pdo->prepare("
  SELECT p.*, c.name as category_name, u.username
  FROM posts p
  JOIN categories c ON p.category_id = c.id
  JOIN users u ON p.user_id = u.id
  WHERE p.slug = :slug AND p.status = 'published'
");
$stmt->execute(['slug' => $slug]);
$post = $stmt->fetch();

if (!$post) {
  http_response_code(404);
  die('Post not found');
}

$commentStmt = $pdo->prepare(
  "SELECT * FROM comments WHERE post_id = :post_id AND status = 'approved' ORDER BY created_at DESC"
);
$commentStmt->execute(['post_id' => $post['id']]);
$comments = $commentStmt->fetchAll();

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['submit_comment'])) {
  $errors = [];
  $author = trim($_POST['author_name'] ?? '');
  $email = filter_var($_POST['author_email'] ?? '', FILTER_VALIDATE_EMAIL);
  $content = trim($_POST['content'] ?? '');

  if (strlen($author) < 2) $errors[] = 'Name is required';
  if ($email === false) $errors[] = 'Valid email is required';
  if (strlen($content) < 5) $errors[] = 'Comment must be at least 5 characters';

  if (empty($errors)) {
    $insert = $pdo->prepare(
      'INSERT INTO comments (post_id, author_name, author_email, content) VALUES (:post_id, :author, :email, :content)'
    );
    $insert->execute([
      'post_id' => $post['id'],
      'author' => htmlspecialchars($author, ENT_QUOTES, 'UTF-8'),
      'email' => $email,
      'content' => htmlspecialchars($content, ENT_QUOTES, 'UTF-8'),
    ]);
    $success = 'Your comment is pending moderation.';
  }
}
?>
<!DOCTYPE html>
<html>
<head><title><?= htmlspecialchars($post['title']) ?> - <?= SITE_NAME ?></title></head>
<body>
  <article>
    <h1><?= htmlspecialchars($post['title']) ?></h1>
    <p class="meta">By <?= htmlspecialchars($post['username']) ?> in
       <?= htmlspecialchars($post['category_name']) ?></p>
    <div class="content"><?= nl2br(htmlspecialchars($post['content'])) ?></div>
  </article>

  <section class="comments">
    <h2>Comments (<?= count($comments) ?>)</h2>
    <?php foreach ($comments as $comment): ?>
      <div class="comment">
        <strong><?= htmlspecialchars($comment['author_name']) ?></strong>
        <p><?= nl2br(htmlspecialchars($comment['content'])) ?></p>
        <small><?= $comment['created_at'] ?></small>
      </div>
    <?php endforeach; ?>
  </section>
</body>
</html>

Common Mistakes

  1. Not using prepared statements for search queries: Concatenating user input into SQL enables SQL Injection. Always use parameterized queries.
  2. Storing plain text passwords: Hash passwords with password_hash using PASSWORD_ARGON2ID. Never store plain text or unsalted hashes.
  3. Not sanitizing comment output: Comments from users may contain malicious HTML. Use htmlspecialchars on all output.
  4. Missing pagination limits: Without LIMIT and OFFSET, querying thousands of posts loads everything into memory and slows the page.
  5. Not handling duplicate slugs: Two posts with the same title would generate the same slug. Append a random suffix or incrementing number on collision.

Practice Questions

  1. Why use a junction table for post_tags?

    • A many-to-many relationship requires a junction table. One post can have many tags, and one tag can belong to many posts.
  2. What is the purpose of ON DELETE CASCADE?

    • It automatically deletes related records. When a post is deleted, its associated post_tags and comments are also deleted.
  3. How does the pagination query calculate total pages?

    • It counts total published posts, divides by POSTS_PER_PAGE, and rounds up with ceil(). The current page is clamped between 1 and totalPages.
  4. Why regenerate the session ID after login?

    • To prevent session fixation attacks where an attacker sets the victim's session ID to a known value.
  5. Challenge: Add a Markdown editor for posts using a JavaScript library like SimpleMDE. Store the raw Markdown in the database, render it to HTML on the server side, and sanitize the HTML output to prevent XSS.

Mini Project

Extend the blog with advanced features:

  • Add a WYSIWYG editor (TinyMCE or CKEditor) for post content.
  • Implement scheduled publishing: posts with a future published_at date are not shown until that time.
  • Add social sharing buttons for Twitter, Facebook, and LinkedIn.
  • Create an RSS feed at /feed.xml that outputs the 20 most recent posts.
  • Add a newsletter signup form that stores emails and integrates with Mailchimp or SendGrid.

FAQ

What database is best for a PHP blog?

MySQL or MariaDB is the standard choice. PostgreSQL also works with PDO and offers advanced features like full-text search.

How do I handle image uploads for blog posts?

Store images in a dedicated uploads/ directory with hashed filenames. Store only the file path in the database. Use intervention/image for resizing.

Is it safe to display user-submitted comments?

Displaying raw user content is an XSS risk. Always use htmlspecialchars when rendering comments. Consider a moderation queue for all comments.

How do I make URLs SEO-friendly?

Use slugs instead of numeric IDs. Generate slugs from the title, ensure uniqueness, and include the slug in the URL: /post/my-first-post.

What's Next

Now build a REST API Project that serves blog data to mobile apps and single-page applications.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro