Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A PHP comment system with replies needs three things working together: a comment row that points to its parent, prepared SQL statements for every user-supplied value, and context-appropriate output encoding when text is rendered. The pattern below uses PDO, a nullable parent_id, POST/redirect/GET submission, and an in-memory tree for displaying threaded comments.

Choose the reply relationship first

Store each comment in one table. A top-level comment has parent_id = NULL; a reply stores the ID of the comment it answers. Every row also needs to identify the page or article so a reply cannot accidentally appear in another discussion.

Column Purpose Typical value
id Unique comment identifier Integer or database-specific ID
page_id Article, post, or page owning the thread The current page’s ID
parent_id Immediate parent comment NULL for a root comment
author_id or display name Author identity Your authenticated user ID or approved name
body Comment text Stored as plain text
created_at Creation time A database timestamp

This model supports one-level replies or deeper nesting. Whether you allow unlimited depth, impose a maximum, moderate comments, or paginate large threads is an application decision rather than a PHP requirement.

Example table and indexes

The following is a starting point; adapt types and foreign-key syntax to your database engine.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
CREATE TABLE comments (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    page_id    BIGINT UNSIGNED NOT NULL,
    parent_id  BIGINT UNSIGNED NULL,
    author_id  BIGINT UNSIGNED NULL,
    body       TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX comments_page_parent (page_id, parent_id),
    INDEX comments_parent (parent_id)
);

An index beginning with page_id helps retrieve one page’s discussion and then group its replies. Add foreign keys, delete behavior, uniqueness rules, and moderation columns only after deciding how your application handles deleted users, removed comments, and whole reply subtrees.

Build the POST handler with PDO

Use a POST request to create a comment, bind values through a prepared statement, then redirect to the page. PHP’s PDO documentation states that calling PDO::prepare() and PDOStatement::execute() helps prevent SQL injection by avoiding manual quoting and escaping of parameters.

  1. Read and validate expected fields

    Obtain the page ID, body, and optional parent ID from the request. Validate that IDs are integers in the range your application accepts. Trim the body and reject an empty value or a value beyond your chosen length.

  2. Verify the parent belongs to this thread

    If a parent ID was submitted, query it by both its ID and the current page_id. Reject it when no matching row exists. This prevents attaching a reply to a comment from another article.

    Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  3. Insert bound values

    $sql = 'INSERT INTO comments
            (page_id, parent_id, author_id, body)
            VALUES (:page_id, :parent_id, :author_id, :body)';
    
    $stmt = $pdo->prepare($sql);
    $stmt->execute([
        ':page_id'   => $pageId,
        ':parent_id' => $parentId,   // NULL for a top-level comment
        ':author_id' => $authorId,
        ':body'      => $body
    ]);

    Placeholders represent complete data literals. They cannot stand in for table names, column names, keywords, or arbitrary SQL fragments, so any dynamic identifier must come from a fixed allow-list rather than request text.

  4. Redirect after success

    Send a redirect to the page after the insert and terminate the request. The POST/redirect/GET pattern prevents a browser refresh from submitting the same form again.

Do not confuse filtering, validation, escaping, and binding

  • Validation asks whether a value has the required type, range, relationship, or format. For example, a parent ID must be an integer and must belong to the current page.
  • PDO binding keeps SQL data separate from SQL syntax. Do not replace it with string concatenation or manual quote escaping.
  • HTML escaping protects the HTML text context when a stored comment is printed.
  • Input filtering is not automatic safety. PHP’s filter_input() uses FILTER_DEFAULT, an alias of FILTER_UNSAFE_RAW, unless you explicitly select a filter; retrieving a value does not validate it by itself.

Render comments as a tree

Fetch comments for the current page, index them by ID, and attach each row to its parent’s children. The code below renders arbitrary depth. Add a depth limit if your product requires one.

$stmt = $pdo->prepare(
    'SELECT id, parent_id, author_id, body, created_at
     FROM comments
     WHERE page_id = :page_id
     ORDER BY created_at ASC, id ASC'
);
$stmt->execute([':page_id' => $pageId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

$byId = [];
$roots = [];
foreach ($rows as $row) {
    $row['children'] = [];
    $byId[$row['id']] = $row;
}
foreach ($byId as $id => $row) {
    if ($row['parent_id'] === null) {
        $roots[] = $id;
    } elseif (isset($byId[$row['parent_id']])) {
        $byId[$row['parent_id']]['children'][] = $id;
    }
}

function e(string $value): string {
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function renderComments(array $ids, array $byId, int $depth = 0): void {
    echo '<ul class="comments">';
    foreach ($ids as $id) {
        $comment = $byId[$id];
        echo '<li class="comment">';
        echo '<p>' . nl2br(e($comment['body'])) . '</p>';
        echo '<small>' . e($comment['created_at']) . '</small>';
        if ($comment['children']) {
            renderComments($comment['children'], $byId, $depth + 1);
        }
        echo '</li>';
    }
    echo '</ul>';
}

renderComments($roots, $byId);

The helper uses UTF-8, substitutes invalid byte sequences, and escapes quotes as well as angle brackets and ampersands. Set the document and database connection to the encoding your application actually uses. HTML escaping is for HTML text; it does not make a value safe for SQL, a URL, JavaScript, or another output context.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Accept replies safely in the form

Include the parent ID as a hidden field only when the user is replying. Treat it as untrusted input anyway: parse it, verify that it exists on the same page, and apply your rules for locked, deleted, or moderated parents.

<form method="post" action="/comments">
    <input type="hidden" name="page_id" value="<?= e((string) $pageId) ?>">
    <input type="hidden" name="parent_id" value="<?= e((string) $parentId) ?>">
    <textarea name="body" required maxlength="5000"></textarea>
    <button type="submit">Post comment</button>
</form>

Protect the endpoint with your normal authentication and authorization checks. If your application uses cookies for login, add CSRF protection; prepared statements and HTML escaping do not address cross-site request forgery.

Decide how much nesting to expose

One-level replies

Allow replies only to root comments and display a flat list of responses beneath each root. This is easiest to moderate and paginate.

Bounded nesting

Store the same parent_id relationship but reject a reply whose ancestor depth exceeds your chosen limit. The limit is a product rule, not a PHP default.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Deeper trees

Allow replies to replies and render recursively, as in the example. For large discussions, consider pagination or loading children separately so one request does not build an unbounded tree in memory.

Common failure modes

  • Replies appear at the top level: the insert is writing NULL instead of the verified parent ID, or the renderer is ignoring parent_id.
  • A reply joins the wrong article: the parent lookup checks only id; require both id and page_id.
  • User text becomes markup: the body is echoed without htmlspecialchars(). Escape at output, using the actual document encoding.
  • SQL errors or injection risk: values are concatenated into SQL, or placeholders are being used for identifiers. Bind values and allow-list any dynamic SQL structure.
  • Duplicate comments after refresh: the form response renders directly after POST. Redirect after a successful transaction.
  • Unexpected accepted input: code assumes filter_input() validates by default. Select explicit validation rules and enforce relationships in server-side code.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.