Add News and Comments with PHP and MySQL

Published Updated

A PHP news page stores posts and comments in separate MySQL tables. Each comment belongs to a post and stays hidden until a moderator approves it.

This PHP tutorial adds comments to an existing database app. You will need a PDO connection, mbstring, session-based CSRF helpers, and a moderator login. The examples below cover the queries and handlers; they do not include a complete login system or admin interface.

Guide Path

Read Build Dynamic Sites With PHP and MySQL first if the database connection and request handlers are not in place yet. After this step, add search across the news content.

Create the Tables

Keep posts and comments in separate tables because comments depend on posts, and the database should know that relationship.

CREATE TABLE news_posts (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    slug VARCHAR(255) NOT NULL UNIQUE,
    body TEXT NOT NULL,
    published_at DATETIME NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE news_comments (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    post_id BIGINT UNSIGNED NOT NULL,
    display_name VARCHAR(120) NOT NULL,
    body TEXT NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX news_comments_post_created_idx (post_id, created_at),
    CONSTRAINT news_comments_post_fk
        FOREIGN KEY (post_id) REFERENCES news_posts (id)
        ON DELETE CASCADE
);

Use InnoDB for both tables so MySQL enforces the foreign key. Store dates consistently, for example in UTC, so the publication filter uses the same timezone as your saved timestamps.

Fetch a Post with Its Comments

Avoid joining everything together on the first pass. Fetch the post, then fetch the comments that belong to it. The code stays plain, and the app can enforce a moderation filter without hiding it in template logic.

<?php

function findPostBySlug(PDO $pdo, string $slug): array
{
    $statement = $pdo->prepare(
        "SELECT id, title, body, published_at
         FROM news_posts
         WHERE slug = :slug
           AND published_at <= CURRENT_TIMESTAMP
         LIMIT 1",
    );

    $statement->execute(["slug" => $slug]);
    $post = $statement->fetch(PDO::FETCH_ASSOC);

    if (! $post) {
        throw new RuntimeException("Post not found.");
    }

    return $post;
}

function approvedCommentsForPost(PDO $pdo, int $postId): array
{
    $statement = $pdo->prepare(
        "SELECT display_name, body, created_at
         FROM news_comments
         WHERE post_id = :post_id AND status = 'approved'
         ORDER BY created_at ASC, id ASC",
    );

    $statement->execute(["post_id" => $postId]);

    return $statement->fetchAll(PDO::FETCH_ASSOC);
}

One function fetches the published post. The other fetches only its approved comments. Add pagination when a post has too many comments to load at once.

Validate Comment Input

Check the name and comment length on the server, verify the CSRF token, and save new comments as pending.

<?php

function createComment(PDO $pdo, int $postId, string $name, string $body): void
{
    $name = trim($name);
    $body = trim($body);

    if ($name === "" || mb_strlen($name) > 120) {
        throw new InvalidArgumentException("Use a display name of 1 to 120 characters.");
    }

    if ($body === "" || mb_strlen($body) > 2000) {
        throw new InvalidArgumentException("Write a comment of 1 to 2000 characters.");
    }

    $statement = $pdo->prepare(
        "INSERT INTO news_comments (post_id, display_name, body, status)
         VALUES (:post_id, :display_name, :body, 'pending')",
    );

    $statement->execute([
        "post_id" => $postId,
        "display_name" => $name,
        "body" => $body,
    ]);
}

A pending comment is saved but not displayed publicly. A moderator can review it before approval.

The request handler should derive the post from a published slug and verify the form token before the prepared insert runs:

<?php

session_start();

if ($_SERVER["REQUEST_METHOD"] !== "POST") {
    http_response_code(405);
    exit("Use POST to submit a comment.");
}
foreach (["post_slug", "display_name", "body", "csrf_token"] as $field) {
    if (! isset($_POST[$field]) || ! is_string($_POST[$field])) {
        http_response_code(400);
        exit("Invalid comment fields.");
    }
}
$pdo = db();
try {
    $post = findPostBySlug($pdo, $_POST["post_slug"]);
} catch (PDOException $exception) {
    throw $exception;
} catch (RuntimeException $exception) {
    http_response_code(404);
    exit("Post not found.");
}
$old = [
    "display_name" => $_POST["display_name"] ?? "",
    "body" => $_POST["body"] ?? "",
];
$errors = [];

try {
    verifyCsrfToken($_POST["csrf_token"] ?? "");

    createComment(
        $pdo,
        (int) $post["id"],
        $old["display_name"],
        $old["body"],
    );

    header("Location: /news/" . rawurlencode($_POST["post_slug"] ?? ""), true, 303);
    return;
} catch (PDOException $exception) {
    http_response_code(500);
    exit("The comment could not be saved. Please try again later.");
} catch (InvalidArgumentException | RuntimeException $exception) {
    $errors["comment"] = $exception->getMessage();
    $comments = approvedCommentsForPost($pdo, (int) $post["id"]);
    require __DIR__ . "/../views/news-show.php";
    return;
}

The browser can request a slug, but it cannot choose an unpublished row or set the moderation status. Validation and token failures now return to the form through the same $errors and escaped old-input pattern used in step 1 instead of becoming an unhandled 500 response.

Who Can Approve Comments in a PHP App?

Only an authenticated moderator should approve comments in a PHP app. Check the moderator’s role and CSRF token on the server before updating the comment, even when readers can comment anonymously.

<?php

function approveComment(PDO $pdo, int $commentId): bool
{
    $statement = $pdo->prepare(
        "UPDATE news_comments
         SET status = 'approved'
         WHERE id = :id AND status = 'pending'",
    );

    $statement->execute(["id" => $commentId]);

    return $statement->rowCount() === 1;
}

// In a POST-only route, after starting the session and loading your helpers:
requireModerator();
$token = $_POST["csrf_token"] ?? null;
$commentId = filter_var($_POST["comment_id"] ?? null, FILTER_VALIDATE_INT, [
    "options" => ["min_range" => 1],
]);
if (! is_string($token) || $commentId === false) {
    http_response_code(400);
    exit("Invalid approval request.");
}
verifyCsrfToken($token);
approveComment(db(), $commentId);

Implement requireModerator() in your application to reject anyone without the moderator role. Start with the PHP authentication and sessions guide, then add the role check. Load that helper, db(), and verifyCsrfToken() before running this handler. The browser supplies a comment ID, but cannot choose its new status.

Escape on Output

Store the submitted text, then escape it for the HTML context where it appears.

<article>
    <h1><?= htmlspecialchars($post["title"], ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8") ?></h1>
    <div><?= nl2br(htmlspecialchars($post["body"], ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8")) ?></div>
</article>

<ol>
    <?php foreach ($comments as $comment): ?>
        <li>
            <strong><?= htmlspecialchars($comment["display_name"], ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8") ?></strong>
            <p><?= nl2br(htmlspecialchars($comment["body"], ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8")) ?></p>
        </li>
    <?php endforeach; ?>
</ol>

Avoid switching to raw HTML because one trusted user wants formatting. If comments need Markdown, add a parser and a sanitizer that you understand. The storage rule and the output rule are separate decisions.

Common Pitfalls & Debugging

Pending Comments Appear on the Public Page

Symptom: a newly submitted comment appears before a moderator approves it. Cause: the public read query selects every child row for the post. Fix: keep status = 'approved' in the prepared public query and use a separate authorized query for the moderation screen.

A Comment Is Attached to the Wrong Post

Symptom: changing a hidden post ID attaches a comment to another or unpublished article. Cause: the handler trusts the submitted parent ID. Fix: resolve a published post from the requested slug on the server, then pass that row's integer ID to the insert.

Comment Text Changes the Page Markup

Symptom: a display name or comment body adds elements or scripts to the page. Cause: stored text is rendered as HTML. Fix: pass each value through htmlspecialchars() and apply nl2br() only after escaping the body.

Frequently Asked Questions

Why use separate post and comment tables?

A post and a comment have different fields, permissions, and lifecycles. Separate tables let one post own many comments through a foreign key, while each query can select only the columns and moderation state its screen needs.

Can a site accept anonymous comments?

A PHP site can accept anonymous comments without requiring accounts. Validate submissions, verify CSRF tokens, limit repeated requests, moderate comments, and escape displayed text. A login helps identify a commenter but does not replace those protections.

Should comments be deleted with their post?

Comments can be deleted automatically with their post by using ON DELETE CASCADE. Keep separate records instead if your moderation or retention policy requires them to survive the post’s removal.

When should comments support Markdown?

Comments need Markdown only when readers need formatting such as lists or code blocks. Parse Markdown and sanitize the generated HTML before displaying it. Plain-text comments are simpler to handle safely.

Can a comment be edited after approval?

An approved comment can be edited if the application provides an edit handler and checks who owns the comment. Return changed text to pending review when edits require moderation. This tutorial does not implement editing.

Should the post list show a comment count?

A news post list can show the number of approved comments for each post. Use a grouped count or a maintained counter instead of running one extra query per post. Exclude pending comments from the public count.

Sources

  1. [1]
    PDO::prepare
    (php.net)
  2. [2]
  3. [3]
  4. [4]
  5. [5]
  6. [6]