PHP: Project: Blog System Part 2

In the previous lesson we built the blog's skeleton. This lesson adds the finishing touches—editing, deletion, comments, pagination, and security hardening—transforming it into a project that's truly ready to deploy and showcase.

1. Post Editing

(1) Adding Methods to the Post Model

PHP
<?php
// Add these methods to app/Models/Post.php

public function findByUser(int $id, int $userId): ?array
{
    $stmt = $this->db->prepare(
        "SELECT * FROM posts WHERE id = :id AND user_id = :userId"
    );
    $stmt->execute(['id' => $id, 'userId' => $userId]);
    $post = $stmt->fetch();
    return $post ?: null;
}

public function update(int $id, int $userId, string $title, string $content): bool
{
    $stmt = $this->db->prepare(
        "UPDATE posts SET title = :title, content = :content 
         WHERE id = :id AND user_id = :userId"
    );
    $stmt->execute([
        'id'      => $id,
        'userId'  => $userId,
        'title'   => $title,
        'content' => $content,
    ]);
    return $stmt->rowCount() > 0;
}

public function delete(int $id, int $userId): bool
{
    $stmt = $this->db->prepare(
        "DELETE FROM posts WHERE id = :id AND user_id = :userId"
    );
    $stmt->execute(['id' => $id, 'userId' => $userId]);
    return $stmt->rowCount() > 0;
}

public function getByUser(int $userId): array
{
    $stmt = $this->db->prepare(
        "SELECT * FROM posts WHERE user_id = :userId ORDER BY created_at DESC"
    );
    $stmt->execute(['userId' => $userId]);
    return $stmt->fetchAll();
}

(2) Corresponding Controller Methods

PHP
<?php
// Add these methods to PostController

/** Edit form */
public function editForm(array $params): string
{
    $this->requireLogin();
    $post = $this->post->findByUser((int)$params['id'], $_SESSION['user']['id']);
    if (!$post) {
        http_response_code(404);
        return '<h2>Post not found or you do not have permission to edit it</h2>';
    }
    ob_start();
    require __DIR__ . '/../../views/posts/edit.php';
    return ob_get_clean();
}

/** Handle editing */
public function edit(array $params): string
{
    $this->requireLogin();
    $userId = $_SESSION['user']['id'];
    $id = (int)$params['id'];

    $title   = trim($_POST['title'] ?? '');
    $content = trim($_POST['content'] ?? '');

    if ($title === '' || $content === '') {
        return '<p style="color:red">Title and content cannot be empty</p><a href="javascript:history.back()">Go back</a>';
    }

    $success = $this->post->update($id, $userId, $title, $content);
    if (!$success) {
        return '<p style="color:red">Post not found or you do not have permission to edit it</p>';
    }

    header("Location: /posts/{$id}");
    exit;
}

/** Delete a post */
public function delete(array $params): void
{
    $this->requireLogin();
    $userId = $_SESSION['user']['id'];
    $id = (int)$params['id'];

    $this->post->delete($id, $userId);
    header("Location: /posts/my");
    exit;
}

/** My posts */
public function myPosts(): string
{
    $this->requireLogin();
    $posts = $this->post->getByUser($_SESSION['user']['id']);
    ob_start();
    require __DIR__ . '/../../views/posts/my.php';
    return ob_get_clean();
}

2. Comment Functionality

(1) Create the Comments Table

▶ Example: Implementing Comments

SQL
CREATE TABLE comments (
    id INT AUTO_INCREMENT PRIMARY KEY,
    post_id INT NOT NULL,
    user_id INT NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
▶ Try it Yourself

Output:

TEXT 📖 Display only
(no visible output - class defined)

(2) Comment Model

PHP
<?php
// app/Models/Comment.php
namespace App\Models;

use App\Core\Database;
use PDO;

class Comment
{
    private PDO $db;

    public function __construct()
    {
        $this->db = Database::getInstance();
    }

    public function getByPost(int $postId): array
    {
        $stmt = $this->db->prepare(
            "SELECT c.*, u.username 
             FROM comments c 
             JOIN users u ON c.user_id = u.id 
             WHERE c.post_id = :postId 
             ORDER BY c.created_at ASC"
        );
        $stmt->execute(['postId' => $postId]);
        return $stmt->fetchAll();
    }

    public function create(int $postId, int $userId, string $content): int
    {
        $stmt = $this->db->prepare(
            "INSERT INTO comments (post_id, user_id, content) 
             VALUES (:post_id, :user_id, :content)"
        );
        $stmt->execute([
            'post_id' => $postId,
            'user_id' => $userId,
            'content' => $content,
        ]);
        return (int)$this->db->lastInsertId();
    }
}

(3) Post Detail Page — With Comments View

PHP
<?php
// views/posts/show.php (improved version)
$commentModel = new \App\Models\Comment();
$comments = $commentModel->getByPost($post['id']);

$title = $post['title']; 
ob_start();
?>

<article>
    <h2><?= htmlspecialchars($post['title']) ?></h2>
    <div class="post-meta">
        Author: <?= htmlspecialchars($post['username']) ?> | 
        <?= $post['created_at'] ?>
    </div>
    <div style="line-height:1.8;margin:20px 0">
        <?= nl2br(htmlspecialchars($post['content'])) ?>
    </div>
    
    <?php if (isset($_SESSION['user']) && $_SESSION['user']['id'] === (int)$post['user_id']): ?>
        <p>
            <a href="/posts/<?= $post['id'] ?>/edit">Edit</a> | 
            <a href="/posts/<?= $post['id'] ?>/delete" onclick="return confirm('Confirm deletion?')">Delete</a>
        </p>
    <?php endif; ?>
</article>

<!-- Comment List -->
<h3>Comments (<?= count($comments) ?>)</h3>
<?php foreach ($comments as $c): ?>
    <div style="border-bottom:1px solid #eee;padding:10px 0">
        <strong><?= htmlspecialchars($c['username']) ?></strong>
        <span style="color:#999;font-size:12px">
            <?= date('Y-m-d H:i', strtotime($c['created_at'])) ?>
        </span>
        <p><?= nl2br(htmlspecialchars($c['content'])) ?></p>
    </div>
<?php endforeach; ?>

<!-- Post a Comment -->
<?php if (isset($_SESSION['user'])): ?>
    <h4>Leave a Comment</h4>
    <form method="POST" action="/posts/<?= $post['id'] ?>/comment">
        <textarea name="content" rows="3" required placeholder="Write your comment..."></textarea>
        <button type="submit" style="margin-top:8px">Submit</button>
    </form>
<?php else: ?>
    <p><a href="/login">Log in</a> to leave a comment</p>
<?php endif; ?>

<p style="margin-top:20px"><a href="/posts">← Back to posts</a></p>

<?php $content = ob_get_clean(); require __DIR__ . '/../layout.php'; ?>

3. Pagination

PHP
<?php
// Post model: paginated queries
public function getPaginated(int $page = 1, int $perPage = 10): array
{
    $offset = ($page - 1) * $perPage;
    
    $stmt = $this->db->prepare(
        "SELECT p.*, u.username 
         FROM posts p 
         JOIN users u ON p.user_id = u.id 
         WHERE p.status = 'published' 
         ORDER BY p.created_at DESC 
         LIMIT :limit OFFSET :offset"
    );
    // PDO LIMIT/OFFSET must use bindValue with explicit types
    $stmt->bindValue(':limit', $perPage, \PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, \PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll();
}

public function count(): int
{
    return (int)$this->db->query(
        "SELECT COUNT(*) FROM posts WHERE status = 'published'"
    )->fetchColumn();
}

▶ Example: Pagination Component

Output:

TEXT 📖 Display only
(no visible output - function defined)
PHP
<?php
// Pagination HTML component
function renderPagination(int $currentPage, int $totalPages, string $baseUrl): string
{
    if ($totalPages <= 1) return '';
    
    $html = '<div style="display:flex;gap:8px;margin-top:20px">';
    
    for ($i = 1; $i <= $totalPages; $i++) {
        $active = $i === $currentPage ? 'background:#4a90d9;color:white' : '';
        $html .= "<a href='{$baseUrl}?page={$i}' 
                  style='padding:6px 12px;border:1px solid #ddd;border-radius:4px;
                  text-decoration:none;{$active}'>{$i}</a>";
    }
    
    $html .= '</div>';
    return $html;
}

Output:

TEXT 📖 Display only
&lt;hello&gt;

4. Security Hardening Checklist

PHP
// Security hardening summary

// 1. Escape all output with htmlspecialchars
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');

// 2. Use prepared statements for all database operations
$stmt = $pdo->prepare("UPDATE posts SET title = :t WHERE id = :id AND user_id = :uid");
$stmt->execute(['t' => $title, 'id' => $id, 'uid' => $_SESSION['user']['id']]);

// 3. Verify permissions on sensitive operations (not just hiding buttons—validate on the backend)
public function delete(array $params): void
{
    $this->requireLogin();
    // ✅ Verify the current user is the post author
    $this->post->delete((int)$params['id'], $_SESSION['user']['id']);
}

// 4. Session security configuration
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1');   // Enable when using HTTPS
ini_set('session.cookie_samesite', 'Lax');

// 5. Never store plain-text passwords
$hash = password_hash($password, PASSWORD_DEFAULT);

// 6. CSRF Token (simplified version)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if ($_POST['_token'] !== $_SESSION['_token']) {
        die('CSRF attack blocked');
    }
}
// In forms: <input type="hidden" name="_token" value="<?= $_SESSION['_token'] ?>">

5. Deployment Checklist

TEXT 📖 Display only
Pre-deployment checklist:
□ display_errors = Off (production)
□ error_log path is writable
□ Database password is not root/empty
□ session.cookie_secure = 1 (if using HTTPS)
□ Upload directory permissions are correct (PHP execution disallowed)
□ .env file is not web-accessible (store outside the web root)
□ SSL certificate configured (Let's Encrypt is free)
□ robots.txt and sitemap.xml generated

▶ Example: CSRF Token Helper for Form Protection

Output:

TEXT 📖 Display only
Form submitted safely!
PHP
<?php
class CsrfToken {
    public static function generate(): string {
        $token = bin2hex(random_bytes(32));
        $_SESSION['_csrf'] = $token;
        return $token;
    }

    public static function check(string $token): bool {
        return isset($_SESSION['_csrf']) && hash_equals($_SESSION['_csrf'], $token);
    }

    public static function field(): string {
        return '<input type="hidden" name="_csrf" value="' . self::generate() . '">';
    }
}

session_start();

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!CsrfToken::check($_POST['_csrf'] ?? '')) {
        die('CSRF token mismatch — request blocked.');
    }
    echo "Form submitted safely!";
}
?>

Output:

TEXT 📖 Display only
Output displayed

❓ FAQ

Q As the blog system gains more features, the code is getting messy. What now?
A This is exactly the problem frameworks (Laravel/Symfony) solve. You now understand Router, Controller, Model, and View concepts. When you learn Laravel in the next lesson, you'll find yourself thinking, "Isn't this just a more elegant version of what I built by hand?"
Q How do I prevent comment spam?
A Progressive solutions: (1) require login to comment (simplest); (2) add reCAPTCHA verification (free); (3) content moderation (sensitive word filtering + manual review); (4) rate limiting (same user can't comment again within 60 seconds).

❓ FAQ

Q Why do I need CSRF protection for my blog admin?
A Without CSRF (Cross-Site Request Forgery) protection, an attacker could craft a malicious link or form that performs admin actions using your logged-in session. The fix: generate a random token stored in the session, include it as a hidden field in every admin form, and verify it server-side before processing the request.

📖 Summary

📝 Exercises

  1. Add "Edit Post" and "Delete Post" functionality to the blog system (only the author can perform these actions).
  2. Implement the comment feature: the post detail page shows a comment list + logged-in users can post comments.
  3. Add pagination to the post list (5 posts per page), supporting pagination on both the homepage and the post list page.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏