Skip to content

Threaded Comments

Selbstreferenzierende Kommentar-Threads mit Tiefenlimits und Soft Delete implementieren.

Übersicht

Ein Threaded-Comment-System hat eine Tabelle, die auf sich selbst verweist. Jeder Kommentar kennt seine parent_id (null für Top-Level), seine depth (0-basiert) und seinen status. Antworten werden in der Antwortstruktur innerhalb ihres Elternkommentars verschachtelt.

Datenbankschema

sql
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)
);

depth ist in der Zeile denormalisiert, um rekursive Vorfahren-Abfragen bei jedem Insert zu vermeiden.

Maximale Tiefe

Ein Tiefenlimit beim Schreiben durchsetzen:

php
public const int MAX_DEPTH = 3;

public function canHaveReplies(): bool
{
    return $this->depth < self::MAX_DEPTH;
}

Im Routen-Handler vor dem Einfügen prüfen:

php
if (!$parent->canHaveReplies()) {
    return $this->problems->create($request, 'unprocessable-entity', 'Maximum comment depth reached.', 422, '');
}

$comment = $this->repo->addComment(
    $parent->postId, $parentId, $authorName, $body, $parent->depth + 1, $now,
);

Soft Delete

Soft Delete ersetzt den Body durch [deleted] und setzt status = 'deleted'. Kindkommentare bleiben erhalten:

php
public function softDelete(int $id): void
{
    $this->executor->execute(
        "UPDATE comments SET status = 'deleted', body = '[deleted]' WHERE id = ?",
        [$id],
    );
}

Der Baumlade-Vorgang gibt gelöschte Kommentare mit [deleted]-Bodies zurück, damit die Thread-Struktur kohärent bleibt — Leser sehen einen Platzhalter, wo der gelöschte Kommentar war, und seine Kindkommentare sind noch sichtbar.

Ein Versuch, auf einen gelöschten Kommentar zu antworten, gibt 409 zurück:

php
if ($parent->isDeleted()) {
    return $this->problems->create($request, 'conflict', 'Cannot reply to a deleted comment.', 409, '');
}

Kommentarbaum ohne N+1 aufbauen

Alle Kommentare für einen Beitrag in einer einzelnen Abfrage laden, geordnet nach ID (Eltern haben immer niedrigere IDs als ihre Kinder). Dann den Baum in PHP mit zwei Durchläufen zusammenbauen:

php
// Durchlauf 1: Rohzeilen-Map und Kinder-ID-Adjazenzliste aufbauen
foreach ($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;
    }
}

// Durchlauf 2: Rekursiv Comment-Value-Objekte von Wurzeln aufbauen
return $this->buildTree($roots, $rowMap, $childIds);

Rohzeilen und int[]-Kinder-ID-Listen von Comment-Value-Objekten zu trennen vermeidet PHPStan-Typverwirrung beim Arbeiten mit readonly-Klassen.

Zeilendaten von Value-Objekten trennen

Wenn man einen Baum von readonly Value-Objekten rekursiv zusammenbaut, braucht PHPStan klare Typgrenzen. Das funktionierende Muster:

  1. Durchlauf 1array<int, array<string, mixed>> $rowMap und array<int, int[]> $childIds aus Rohzeilen aufbauen. Noch keine Value-Objekte.
  2. Durchlauf 2buildTree() nimmt nur int[]-IDs und die zwei Maps, rekursiert und hydratisiert Comment-Objekte mit vollständig zusammengebauten Kinder-Arrays.

Dies vermeidet das Mischen von Comment-Objekten und int-IDs im selben Array, was einen Union-Typ erzeugen würde, den PHPStan nicht einengen kann.

Routen-Zusammenfassung

MethodePfadBeschreibung
POST/postsBeitrag erstellen
GET/posts/{id}Beitrag abrufen
POST/posts/{id}/commentsTop-Level-Kommentar hinzufügen
GET/posts/{id}/commentsKommentarbaum abrufen
POST/comments/{id}/repliesAuf Kommentar antworten
DELETE/comments/{id}Kommentar soft löschen

Design-Hinweise

  • depth ist in der Zeile gespeichert (denormalisiert), um rekursive Vorfahren-Abfragen bei jedem Insert zu vermeiden.
  • ORDER BY id ASC garantiert, dass Eltern vor ihren Kindern beim Laden der flachen Liste erscheinen.
  • Soft Delete bewahrt die Thread-Struktur — Hard Delete würde Kindkommentare zu Waisen machen.
  • Auf einen gelöschten Kommentar zu antworten ist gesperrt (409), um Ghost-Threads zu verhindern.

Veröffentlicht unter der MIT-Lizenz.