A threaded comment system has one table that references itself. Each comment knows its parent_id (null for top-level), its depth (0-based), and its status. Replies nest inside their parent in the response tree.
CREATE TABLE comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, parent_id INTEGER, author_name TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'published', depth INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, FOREIGN KEY (post_id) REFERENCES posts(id), FOREIGN KEY (parent_id) REFERENCES comments(id));
1 2 3 4 5 6 7 8 9 10 11 12
depth is denormalized into the row to avoid recursive ancestor queries on each insert.
Soft delete replaces the body with [deleted] and sets status = 'deleted'. Children are preserved:
php
public function softDelete(int $id): void{ $this->executor->execute( "UPDATE comments SET status = 'deleted', body = '[deleted]' WHERE id = ?", [$id], );}
1 2 3 4 5 6 7
The tree fetch returns deleted comments with [deleted] bodies so the thread structure remains coherent — readers see a placeholder where the deleted comment was, and its children are still visible.
Attempting to reply to a deleted comment returns 409:
php
if ($parent->isDeleted()) { return $this->problems->create($request, 'conflict', 'Cannot reply to a deleted comment.', 409, '');}
Load all comments for a post in a single query ordered by ID (parents always have lower IDs than their children). Then assemble the tree in PHP using two passes:
php
// Pass 1: build raw row map and child-ID adjacency listforeach ($rows as $row) { $rowMap[(int) $row['id']] = $row; $childIds[(int) $row['id']] = [];}foreach ($rowMap as $id => $row) { $parentId = isset($row['parent_id']) ? (int) $row['parent_id'] : null; if ($parentId === null) { $roots[] = $id; } elseif (isset($childIds[$parentId])) { $childIds[$parentId][] = $id; }}// Pass 2: recursively build Comment value objects from rootsreturn $this->buildTree($roots, $rowMap, $childIds);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
Keeping raw rows and int[] child-ID lists separate from Comment value objects avoids PHPStan type confusion when working with readonly classes.
Threaded Comments
Implement self-referencing comment threads with depth limits and soft delete.
Overview
A threaded comment system has one table that references itself. Each comment knows its
parent_id(null for top-level), itsdepth(0-based), and itsstatus. Replies nest inside their parent in the response tree.Database Schema
2
3
4
5
6
7
8
9
10
11
12
depthis denormalized into the row to avoid recursive ancestor queries on each insert.Maximum Depth
Enforce a depth limit at write time:
2
3
4
5
6
In the route handler, check before inserting:
2
3
4
5
6
7
Soft Delete
Soft delete replaces the body with
[deleted]and setsstatus = 'deleted'. Children are preserved:2
3
4
5
6
7
The tree fetch returns deleted comments with
[deleted]bodies so the thread structure remains coherent — readers see a placeholder where the deleted comment was, and its children are still visible.Attempting to reply to a deleted comment returns 409:
2
3
Building the Comment Tree Without N+1
Load all comments for a post in a single query ordered by ID (parents always have lower IDs than their children). Then assemble the tree in PHP using two passes:
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Keeping raw rows and
int[]child-ID lists separate fromCommentvalue objects avoids PHPStan type confusion when working withreadonlyclasses.Separating Row Data from Value Objects
When assembling a tree of readonly value objects recursively, PHPStan needs clean type boundaries. The pattern that works:
array<int, array<string, mixed>> $rowMapandarray<int, int[]> $childIdsfrom raw rows. No value objects yet.buildTree()takes onlyint[]IDs and the two maps, recurses, and hydratesCommentobjects with fully-assembled children arrays.This avoids mixing
Commentobjects andintIDs in the same array, which would cause a union type that PHPStan cannot narrow.Route Summary
POST/postsGET/posts/{id}POST/posts/{id}/commentsGET/posts/{id}/commentsPOST/comments/{id}/repliesDELETE/comments/{id}Design Notes
depthis stored in the row (denormalized) to avoid recursive ancestor queries on each insert.ORDER BY id ASCguarantees parents appear before their children when loading the flat list.