Add News and Comments with PHP and MySQL
A news item has an id, a date, a body, and attached comments. Building that today means PDO, foreign keys, CSRF tokens, and escaped output rather than raw SQL strings, loose escaping, and casual form handling.
This is step 2 of the PHP web-app path. Step 1 gave the app a database boundary and one create form. The next useful feature is a news table with comments, because it forces the app to handle parent rows, child rows, validation, and output escaping.
Think of the app as a small newspaper desk. Posts are published articles, incoming comments wait in a pending tray, and only an authorized editor can move them onto the public page. The schema and queries should preserve that separation.
Guide Path
Read Build Dynamic Sites With PHP and MySQL first if the database and request shape 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 or another engine that actually enforces the foreign key. That sentence looks too obvious until you inherit a database that accepted constraint syntax and quietly ignored it.
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);
} The functions are deliberately small, which makes their ownership easy to review. One function reads a post by slug, while the other reads approved comments for that post. That split gives the next feature a place to attach without turning the template into a query file.
Validate Comment Input
A comment box is hostile input with a friendly label. Validate the fields, require a CSRF token, and store the comment as pending unless the site deliberately allows immediate publication.
Make moderation the default for this exercise. The extra row state keeps the newspaper desk visible in the data model and teaches that public writing surfaces need an owner.
<?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 under 120 characters.");
}
if ($body === "" || mb_strlen($body) > 2000) {
throw new InvalidArgumentException("Write a comment under 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,
]);
} That pending state earns its keep quickly once a public form exists. It is the smallest moderation boundary worth having. Public comments attract spam quickly, and the database should have a place to record the decision.
The request handler should derive the post from a published slug and verify the form token before the prepared insert runs:
<?php
session_start();
$pdo = db();
$post = findPostBySlug($pdo, $_POST["post_slug"] ?? "");
$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 (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.
Moderation Is an Authorization Boundary
Approving a comment is a privileged database write. The handler must load an authenticated moderator before it changes the row, even if the comment form itself accepts anonymous readers.
<?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;
}
requireModerator();
verifyCsrfToken($_POST["csrf_token"] ?? "");
approveComment(db(), (int) ($_POST["comment_id"] ?? 0)); requireModerator() remains the single owner of the editor authorization check. The authentication and sessions reference defines the same boundary shape for a signed-in user; moderation adds the role check before the write. The prepared statement protects the value boundary, while the fixed status transition prevents the browser from supplying arbitrary SQL grammar or publishing states.
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?
Yes, if anonymous participation is a deliberate product choice. The public endpoint still needs server-side validation, CSRF protection, rate limits, spam controls, pending moderation, and output escaping. Authenticated commenting can simplify identity and abuse handling, but it does not replace those controls.
Should comments be deleted with their post?
Use ON DELETE CASCADE when comments have no meaning without the parent post and deletion is the intended policy. Choose a different policy when legal, moderation, or audit requirements require comment records to survive a post's removal.
When should comments support Markdown?
Add Markdown only when readers need formatting that plain text cannot provide. Parse it with a maintained parser, sanitize the generated HTML with a strict allowlist, and test links and code blocks before changing the renderer from escaped text.
Can a comment be edited after approval?
The schema and functions here cover creating and moderating only, not editing. If editing is added, decide deliberately whether an edit returns the comment to pending review or updates the approved text in place.
Should the post list show a comment count?
Yes, when it is cheap to produce. A stored or periodically refreshed count avoids running a separate query against every post just to render a list, which starts to matter as soon as the archive grows.
What This Step Proves
The app now has a parent table, a child table, a write path, a read path, and an output boundary. That is enough structure to make the next feature meaningful without pretending this small project is already a publishing platform.
Next Steps
Search comes next because it exposes whether the schema and route boundaries are holding up. If the news page already mixes SQL, HTML, and validation in one file, search will make that mess visible. If the boundaries are still clean, adding search is an ordinary next step.
This example has been updated from the original CodeWalkers news-with-comments tutorial, which concatenated its queries as SQL strings and accepted comment posts without a token. The project shape is unchanged; the parent and child tables now carry a foreign key and every write crosses a CSRF check.
Sources
-
[1]
PDO::prepare(php.net)
-
[2]
PDOStatement::rowCount(php.net)
-
[3]
MySQL InnoDB and FOREIGN KEY Constraints(dev.mysql.com)
-
[4]
PHP htmlspecialchars(php.net)
-
[5]
PHP Sessions and Security(php.net)
-
[6]
PHP hash_equals(php.net)
Read Next
Start the PHP web-app path with one small dynamic site, a clean data model, PDO queries, and plain request handling.
Use PDO prepared statements to connect PHP to MySQL safely and keep SQL input handling clear.
Build previous and next links in PHP with validated page numbers, PDO queries, LIMIT/OFFSET, and a clear path to cursor pagination.