Skip to content

Comentários em Thread

Implemente threads de comentários com auto-referência com limites de profundidade e soft delete.

Visão Geral

Um sistema de comentários em thread tem uma tabela que referencia a si mesma. Cada comentário conhece seu parent_id (null para nível superior), sua depth (baseado em 0) e seu status. As respostas se aninham dentro de seu pai na árvore de resposta.

Schema do Banco de Dados

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 é desnormalizado na linha para evitar queries de ancestrais recursivas em cada inserção.

Profundidade Máxima

Aplique um limite de profundidade no momento da escrita:

php
public const int MAX_DEPTH = 3;

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

No handler de rota, verifique antes de inserir:

php
if (!$parent->canHaveReplies()) {
    return $this->problems->create($request, 'unprocessable-entity', 'Profundidade máxima de comentário atingida.', 422, '');
}

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

Soft Delete

Soft delete substitui o corpo por [deleted] e define status = 'deleted'. Os filhos são preservados:

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

A busca da árvore retorna comentários excluídos com corpos [deleted] para que a estrutura do thread permaneça coerente — os leitores veem um placeholder onde o comentário excluído estava, e seus filhos ainda são visíveis.

Tentar responder a um comentário excluído retorna 409:

php
if ($parent->isDeleted()) {
    return $this->problems->create($request, 'conflict', 'Não é possível responder a um comentário excluído.', 409, '');
}

Construindo a Árvore de Comentários Sem N+1

Carregue todos os comentários de uma postagem em uma única query ordenada por ID (pais sempre têm IDs menores que seus filhos). Então monte a árvore em PHP usando dois passos:

php
// Passo 1: construir mapa de linhas brutas e lista de adjacência de IDs de filhos
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;
    }
}

// Passo 2: construir recursivamente objetos Comment a partir das raízes
return $this->buildTree($roots, $rowMap, $childIds);

Manter linhas brutas e listas int[] de IDs de filhos separadas dos value objects Comment evita confusão de tipos no PHPStan ao trabalhar com classes readonly.

Separando Dados de Linha de Value Objects

Ao montar uma árvore de value objects readonly recursivamente, o PHPStan precisa de fronteiras de tipo limpas. O padrão que funciona:

  1. Passo 1 — construir array<int, array<string, mixed>> $rowMap e array<int, int[]> $childIds a partir de linhas brutas. Sem value objects ainda.
  2. Passo 2buildTree() recebe apenas IDs int[] e os dois mapas, recursa e hidrata objetos Comment com arrays de filhos completamente montados.

Isso evita misturar objetos Comment e IDs int no mesmo array, o que causaria um tipo union que o PHPStan não consegue restringir.

Resumo das Rotas

MétodoCaminhoDescrição
POST/postsCriar uma postagem
GET/posts/{id}Obter uma postagem
POST/posts/{id}/commentsAdicionar comentário de nível superior
GET/posts/{id}/commentsObter árvore de comentários
POST/comments/{id}/repliesResponder a um comentário
DELETE/comments/{id}Soft-delete de um comentário

Notas de Design

  • depth é armazenado na linha (desnormalizado) para evitar queries de ancestrais recursivas em cada inserção.
  • ORDER BY id ASC garante que pais apareçam antes de seus filhos ao carregar a lista plana.
  • Soft delete preserva a estrutura do thread — hard delete tornaria comentários filhos órfãos.
  • Responder a um comentário excluído é bloqueado (409) para prevenir threads fantasma.

Publicado sob a licença MIT.