diff --git a/public/comments.php b/public/comments.php
index 93a480d8..e7064b8d 100644
--- a/public/comments.php
+++ b/public/comments.php
@@ -1,12 +1,10 @@
 <?php
 namespace Misuzu;
 
+use RuntimeException;
 use Misuzu\AuditLog;
 use Misuzu\Comments\CommentsCategory;
-use Misuzu\Comments\CommentsCategoryNotFoundException;
 use Misuzu\Comments\CommentsPost;
-use Misuzu\Comments\CommentsPostNotFoundException;
-use Misuzu\Comments\CommentsPostSaveFailedException;
 use Misuzu\Comments\CommentsVote;
 use Misuzu\Users\User;
 use Misuzu\Users\UserNotFoundException;
@@ -42,82 +40,97 @@ if($currentUserInfo->isSilenced()) {
     return;
 }
 
+$comments = $msz->getComments();
+
 $commentPerms = $currentUserInfo->commentPerms();
 
-$commentId   = (int)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
-$commentMode =      filter_input(INPUT_GET, 'm');
+$commentId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
+$commentMode = (string)filter_input(INPUT_GET, 'm');
 $commentVote = (int)filter_input(INPUT_GET, 'v', FILTER_SANITIZE_NUMBER_INT);
 
-if($commentId > 0)
+if(!empty($commentId)) {
     try {
-        $commentInfo2 = CommentsPost::byId($commentId);
-    } catch(CommentsPostNotFoundException $ex) {
+        $commentInfo = $comments->getPostById($commentId);
+    } catch(RuntimeException $ex) {
         echo render_info('Post not found.', 404);
         return;
     }
 
+    $categoryInfo = $comments->getCategoryByPost($commentInfo);
+}
+
+if($commentMode !== 'create' && empty($commentInfo)) {
+    echo render_error(400);
+    return;
+}
+
 switch($commentMode) {
     case 'pin':
     case 'unpin':
-        if(!$commentPerms['can_pin'] && !$commentInfo2->isOwner($currentUserInfo)) {
+        if(!$commentPerms['can_pin'] && !$categoryInfo->isOwner($currentUserInfo)) {
             echo render_info("You're not allowed to pin comments.", 403);
             break;
         }
 
-        if($commentInfo2->isDeleted()) {
+        if($commentInfo->isDeleted()) {
             echo render_info("This comment doesn't exist!", 400);
             break;
         }
 
-        if($commentInfo2->hasParent()) {
+        if($commentInfo->isReply()) {
             echo render_info("You can't pin replies!", 400);
             break;
         }
 
         $isPinning = $commentMode === 'pin';
 
-        if($isPinning && $commentInfo2->isPinned()) {
-            echo render_info('This comment is already pinned.', 400);
-            break;
-        } elseif(!$isPinning && !$commentInfo2->isPinned()) {
-            echo render_info("This comment isn't pinned yet.", 400);
-            break;
+        if($isPinning) {
+            if($commentInfo->isPinned()) {
+                echo render_info('This comment is already pinned.', 400);
+                break;
+            }
+
+            $comments->pinPost($commentInfo);
+        } else {
+            if(!$commentInfo->isPinned()) {
+                echo render_info("This comment isn't pinned yet.", 400);
+                break;
+            }
+
+            $comments->unpinPost($commentInfo);
         }
 
-        $commentInfo2->setPinned($isPinning);
-        $commentInfo2->save();
-
-        redirect($redirect . '#comment-' . $commentInfo2->getId());
+        redirect($redirect . '#comment-' . $commentInfo->getId());
         break;
 
     case 'vote':
-        if(!$commentPerms['can_vote'] && !$commentInfo2->isOwner($currentUserInfo)) {
+        if(!$commentPerms['can_vote'] && !$categoryInfo->isOwner($currentUserInfo)) {
             echo render_info("You're not allowed to vote on comments.", 403);
             break;
         }
 
-        if($commentInfo2->isDeleted()) {
+        if($commentInfo->isDeleted()) {
             echo render_info("This comment doesn't exist!", 400);
             break;
         }
 
         if($commentVote > 0)
-            $commentInfo2->addPositiveVote($currentUserInfo);
+            $comments->addPostPositiveVote($commentInfo, $currentUserInfo);
         elseif($commentVote < 0)
-            $commentInfo2->addNegativeVote($currentUserInfo);
+            $comments->addPostNegativeVote($commentInfo, $currentUserInfo);
         else
-            $commentInfo2->removeVote($currentUserInfo);
+            $comments->removePostVote($commentInfo, $currentUserInfo);
 
-        redirect($redirect . '#comment-' . $commentInfo2->getId());
+        redirect($redirect . '#comment-' . $commentInfo->getId());
         break;
 
     case 'delete':
-        if(!$commentPerms['can_delete'] && !$commentInfo2->isOwner($currentUserInfo)) {
+        if(!$commentPerms['can_delete'] && !$categoryInfo->isOwner($currentUserInfo)) {
             echo render_info("You're not allowed to delete comments.", 403);
             break;
         }
 
-        if($commentInfo2->isDeleted()) {
+        if($commentInfo->isDeleted()) {
             echo render_info(
                 $commentPerms['can_delete_any'] ? 'This comment is already marked for deletion.' : "This comment doesn't exist.",
                 400
@@ -125,7 +138,7 @@ switch($commentMode) {
             break;
         }
 
-        $isOwnComment = $commentInfo2->getUserId() === $currentUserInfo->getId();
+        $isOwnComment = $commentInfo->getUserId() === (string)$currentUserInfo->getId();
         $isModAction  = $commentPerms['can_delete_any'] && !$isOwnComment;
 
         if(!$isModAction && !$isOwnComment) {
@@ -133,17 +146,16 @@ switch($commentMode) {
             break;
         }
 
-        $commentInfo2->setDeleted(true);
-        $commentInfo2->save();
+        $comments->deletePost($commentInfo);
 
         if($isModAction) {
             AuditLog::create(AuditLog::COMMENT_ENTRY_DELETE_MOD, [
-                $commentInfo2->getId(),
-                $commentUserId = $commentInfo2->getUserId(),
-                ($commentUserId < 1 ? '(Deleted User)' : $commentInfo2->getUser()->getUsername()),
+                $commentInfo->getId(),
+                $commentUserId = $commentInfo->getUserId(),
+                '<username>',
             ]);
         } else {
-            AuditLog::create(AuditLog::COMMENT_ENTRY_DELETE, [$commentInfo2->getId()]);
+            AuditLog::create(AuditLog::COMMENT_ENTRY_DELETE, [$commentInfo->getId()]);
         }
 
         redirect($redirect);
@@ -155,25 +167,24 @@ switch($commentMode) {
             break;
         }
 
-        if(!$commentInfo2->isDeleted()) {
+        if(!$commentInfo->isDeleted()) {
             echo render_info("This comment isn't in a deleted state.", 400);
             break;
         }
 
-        $commentInfo2->setDeleted(false);
-        $commentInfo2->save();
+        $comments->restorePost($commentInfo);
 
         AuditLog::create(AuditLog::COMMENT_ENTRY_RESTORE, [
-            $commentInfo2->getId(),
-            $commentUserId = $commentInfo2->getUserId(),
-            ($commentUserId < 1 ? '(Deleted User)' : $commentInfo2->getUser()->getUsername()),
+            $commentInfo->getId(),
+            $commentUserId = $commentInfo->getUserId(),
+            '<username>',
         ]);
 
-        redirect($redirect . '#comment-' . $commentInfo2->getId());
+        redirect($redirect . '#comment-' . $commentInfo->getId());
         break;
 
     case 'create':
-        if(!$commentPerms['can_comment'] && !$commentInfo2->isOwner($currentUserInfo)) {
+        if(!$commentPerms['can_comment'] && !$categoryInfo->isOwner($currentUserInfo)) {
             echo render_info("You're not allowed to post comments.", 403);
             break;
         }
@@ -184,12 +195,11 @@ switch($commentMode) {
         }
 
         try {
-            $categoryInfo = CommentsCategory::byId(
-                isset($_POST['comment']['category']) && is_string($_POST['comment']['category'])
-                    ? (int)$_POST['comment']['category']
-                    : 0
-            );
-        } catch(CommentsCategoryNotFoundException $ex) {
+            $categoryId = isset($_POST['comment']['category']) && is_string($_POST['comment']['category'])
+                ? (int)$_POST['comment']['category']
+                : 0;
+            $categoryInfo = $comments->getCategoryById($categoryId);
+        } catch(RuntimeException $ex) {
             echo render_info('This comment category doesn\'t exist.', 404);
             break;
         }
@@ -199,21 +209,23 @@ switch($commentMode) {
             break;
         }
 
-        $commentText  = !empty($_POST['comment']['text'])  && is_string($_POST['comment']['text'])  ?      $_POST['comment']['text']  : '';
-        $commentReply = !empty($_POST['comment']['reply']) && is_string($_POST['comment']['reply']) ? (int)$_POST['comment']['reply'] : 0;
-        $commentLock  = !empty($_POST['comment']['lock'])  && $commentPerms['can_lock'];
-        $commentPin   = !empty($_POST['comment']['pin'])   && $commentPerms['can_pin'];
+        $commentText = !empty($_POST['comment']['text'])  && is_string($_POST['comment']['text']) ? $_POST['comment']['text'] : '';
+        $commentReply = (string)(!empty($_POST['comment']['reply']) && is_string($_POST['comment']['reply']) ? (int)$_POST['comment']['reply'] : 0);
+        $commentLock = !empty($_POST['comment']['lock'])  && $commentPerms['can_lock'];
+        $commentPin = !empty($_POST['comment']['pin'])   && $commentPerms['can_pin'];
 
         if($commentLock) {
-            $categoryInfo->setLocked(!$categoryInfo->isLocked());
-            $categoryInfo->save();
+            if($categoryInfo->isLocked())
+                $comments->unlockCategory($categoryInfo);
+            else
+                $comments->lockCategory($categoryInfo);
         }
 
         if(strlen($commentText) > 0) {
             $commentText = preg_replace("/[\r\n]{2,}/", "\n", $commentText);
         } else {
             if($commentPerms['can_lock']) {
-                echo render_info('The action has been processed.');
+                echo render_info('The action has been processed.', 400);
             } else {
                 echo render_info('Your comment is too short.', 400);
             }
@@ -227,34 +239,24 @@ switch($commentMode) {
 
         if($commentReply > 0) {
             try {
-                $parentCommentInfo = CommentsPost::byId($commentReply);
-            } catch(CommentsPostNotFoundException $ex) {
-                unset($parentCommentInfo);
-            }
+                $parentInfo = $comments->getPostById($commentReply);
+            } catch(RuntimeException $ex) {}
 
-            if(!isset($parentCommentInfo) || $parentCommentInfo->isDeleted()) {
+            if(!isset($parentInfo) || $parentInfo->isDeleted()) {
                 echo render_info('The comment you tried to reply to does not exist.', 404);
                 break;
             }
         }
 
-        $commentInfo2 = (new CommentsPost)
-            ->setUser($currentUserInfo)
-            ->setCategory($categoryInfo)
-            ->setParsedText($commentText)
-            ->setPinned($commentPin);
+        $commentInfo = $comments->createPost(
+            $categoryInfo,
+            $parentInfo ?? null,
+            $currentUserInfo,
+            $commentText,
+            $commentPin
+        );
 
-        if(isset($parentCommentInfo))
-            $commentInfo2->setParent($parentCommentInfo);
-
-        try {
-            $commentInfo2->save();
-        } catch(CommentsPostSaveFailedException $ex) {
-            echo render_info('Something went horribly wrong.', 500);
-            break;
-        }
-
-        redirect($redirect . '#comment-' . $commentInfo2->getId());
+        redirect($redirect . '#comment-' . $commentInfo->getId());
         break;
 
     default:
diff --git a/public/search.php b/public/search.php
index 022f16e0..81c484f5 100644
--- a/public/search.php
+++ b/public/search.php
@@ -1,8 +1,8 @@
 <?php
 namespace Misuzu;
 
+use RuntimeException;
 use Misuzu\Comments\CommentsCategory;
-use Misuzu\Comments\CommentsCategoryNotFoundException;
 use Misuzu\Users\User;
 
 require_once '../misuzu.php';
@@ -15,6 +15,7 @@ if(!empty($searchQuery)) {
 
     // this sure is an expansion
     $news = $msz->getNews();
+    $comments = $msz->getComments();
     $newsPosts = [];
     $newsPostInfos = $news->getPostsBySearchQuery($searchQuery);
     $newsUserInfos = [];
@@ -41,11 +42,8 @@ if(!empty($searchQuery)) {
         else
             $newsCategoryInfos[$categoryId] = $categoryInfo = $news->getCategoryByPost($postInfo);
 
-        $commentsCount = 0;
-        if($postInfo->hasCommentsCategoryId())
-            try {
-                $commentsCount = CommentsCategory::byId($postInfo->getCommentsCategoryId())->getPostCount();
-            } catch(CommentsCategoryNotFoundException $ex) {}
+        $commentsCount = $postInfo->hasCommentsCategoryId()
+            ? $comments->countPosts($postInfo->getCommentsCategoryId(), includeReplies: true) : 0;
 
         $newsPosts[] = [
             'post' => $postInfo,
diff --git a/src/Comments/Comments.php b/src/Comments/Comments.php
new file mode 100644
index 00000000..18e2c91a
--- /dev/null
+++ b/src/Comments/Comments.php
@@ -0,0 +1,524 @@
+<?php
+namespace Misuzu\Comments;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Index\Data\IDbConnection;
+use Index\Data\IDbResult;
+use Misuzu\DbStatementCache;
+use Misuzu\Pagination;
+use Misuzu\Comments\CommentsCategory;
+use Misuzu\Users\User;
+
+class Comments {
+    private IDbConnection $dbConn;
+    private DbStatementCache $cache;
+
+    public function __construct(IDbConnection $dbConn) {
+        $this->dbConn = $dbConn;
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function countAllCategories(User|string|null $owner = null): int {
+        if($owner instanceof User)
+            $owner = (string)$owner->getId();
+
+        $hasOwner = $owner !== null;
+
+        $query = 'SELECT COUNT(*) FROM msz_comments_categories';
+        if($hasOwner)
+            $query .= ' WHERE owner_id = ?';
+
+        $stmt = $this->cache->get($query);
+        $stmt->addParameter(1, $owner);
+        $stmt->execute();
+
+        $count = 0;
+        $result = $stmt->getResult();
+
+        if($result->next())
+            $count = $result->getInteger(0);
+
+        return $count;
+    }
+
+    public function getCategories(
+        User|string|null $owner = null,
+        ?Pagination $pagination = null
+    ): array {
+        if($owner instanceof User)
+            $owner = (string)$owner->getId();
+
+        $hasOwner = $owner !== null;
+        $hasPagination = $pagination !== null;
+
+        $query = 'SELECT category_id, category_name, owner_id, UNIX_TIMESTAMP(category_created), UNIX_TIMESTAMP(category_locked), (SELECT COUNT(*) FROM msz_comments_posts AS cp WHERE cp.category_id = cc.category_id AND comment_deleted IS NULL) AS `category_comments` FROM msz_comments_categories AS cc';
+        if($hasOwner)
+            $query .= ' WHERE owner_id = ?';
+        $query .= ' ORDER BY category_id ASC'; // should order by date but no index on
+        if($hasPagination)
+            $query .= ' LIMIT ? RANGE ?';
+
+        $stmt = $this->cache->get($query);
+
+        $args = 0;
+        if($hasOwner)
+            $stmt->addParameter(++$args, $owner);
+        if($hasPagination) {
+            $stmt->addParameter(++$args, $pagination->getRange());
+            $stmt->addParameter(++$args, $pagination->getOffset());
+        }
+
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        $categories = [];
+
+        while($result->next())
+            $categories[] = new CommentsCategoryInfo($result);
+
+        return $categories;
+    }
+
+    public function getCategoryByName(string $name): CommentsCategoryInfo {
+        $stmt = $this->cache->get('SELECT category_id, category_name, owner_id, UNIX_TIMESTAMP(category_created), UNIX_TIMESTAMP(category_locked), (SELECT COUNT(*) FROM msz_comments_posts AS cp WHERE cp.category_id = cc.category_id AND comment_deleted IS NULL) AS `category_comments` FROM msz_comments_categories AS cc WHERE category_name = ?');
+        $stmt->addParameter(1, $name);
+        $stmt->execute();
+        $result = $stmt->getResult();
+
+        if(!$result->next())
+            throw new RuntimeException('No category with this name found.');
+
+        return new CommentsCategoryInfo($result);
+    }
+
+    public function getCategoryById(string $id): CommentsCategoryInfo {
+        $stmt = $this->cache->get('SELECT category_id, category_name, owner_id, UNIX_TIMESTAMP(category_created), UNIX_TIMESTAMP(category_locked), (SELECT COUNT(*) FROM msz_comments_posts AS cp WHERE cp.category_id = cc.category_id AND comment_deleted IS NULL) AS `category_comments` FROM msz_comments_categories AS cc WHERE category_id = ?');
+        $stmt->addParameter(1, $id);
+        $stmt->execute();
+        $result = $stmt->getResult();
+
+        if(!$result->next())
+            throw new RuntimeException('No category with this ID found.');
+
+        return new CommentsCategoryInfo($result);
+    }
+
+    public function getCategoryByPost(CommentsPostInfo|string $infoOrId): CommentsCategoryInfo {
+        $query = 'SELECT category_id, category_name, owner_id, UNIX_TIMESTAMP(category_created), UNIX_TIMESTAMP(category_locked), (SELECT COUNT(*) FROM msz_comments_posts AS cp WHERE cp.category_id = cc.category_id AND comment_deleted IS NULL) AS `category_comments` FROM msz_comments_categories AS cc WHERE category_id = ';
+
+        if($infoOrId instanceof CommentsPostInfo) {
+            $query .= '?';
+            $param = $infoOrId->getCategoryId();
+        } else {
+            $query .= '(SELECT category_id FROM msz_comments_posts WHERE comment_id = ?)';
+            $param = $infoOrId;
+        }
+
+        $stmt = $this->cache->get($query);
+        $stmt->addParameter(1, $param);
+        $stmt->execute();
+        $result = $stmt->getResult();
+
+        if(!$result->next())
+            throw new RuntimeException('No category belonging to this post found.');
+
+        return new CommentsCategoryInfo($result);
+    }
+
+    public function checkCategoryNameExists(string $name): bool {
+        $stmt = $this->cache->get('SELECT COUNT(*) FROM msz_comments_categories WHERE category_name = ?');
+        $stmt->addParameter(1, $name);
+        $stmt->execute();
+
+        $count = 0;
+        $result = $stmt->getResult();
+
+        if($result->next())
+            $count = $result->getInteger(0);
+
+        return $count > 0;
+    }
+
+    public function ensureCategory(string $name, User|string|null $owner = null): CommentsCategoryInfo {
+        if($this->checkCategoryNameExists($name))
+            return $this->getCategoryByName($name);
+        return $this->createCategory($name, $owner);
+    }
+
+    public function createCategory(string $name, User|string|null $owner = null): CommentsCategoryInfo {
+        if($owner instanceof User)
+            $owner = (string)$owner->getId();
+
+        $name = trim($name);
+        if(empty($name))
+            throw new InvalidArgumentException('$name may not be empty.');
+
+        $stmt = $this->cache->get('INSERT INTO msz_comments_categories (category_name, owner_id) VALUES (?, ?)');
+        $stmt->addParameter(1, $name);
+        $stmt->addParameter(2, $owner);
+        $stmt->execute();
+
+        return $this->getCategoryById((string)$this->dbConn->getLastInsertId());
+    }
+
+    public function deleteCategory(CommentsCategoryInfo|string $category): void {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->getId();
+
+        $stmt = $this->cache->get('DELETE FROM msz_comments_categories WHERE category_id = ?');
+        $stmt->addParameter(1, $category);
+        $stmt->execute();
+    }
+
+    public function updateCategory(
+        CommentsCategoryInfo|string $category,
+        ?string $name = null,
+        bool $updateOwner = false,
+        User|string|null $owner = null
+    ): void {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->getId();
+        if($owner instanceof User)
+            $owner = (string)$owner->getId();
+
+        if($name !== null) {
+            $name = trim($name);
+            if(empty($name))
+                throw new InvalidArgumentException('$name may not be empty.');
+        }
+
+        $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_name = COALESCE(?, category_name), owner_id = IF(?, ?, owner_id) WHERE category_id = ?');
+        $stmt->addParameter(1, $name);
+        $stmt->addParameter(2, $updateOwner ? 1 : 0);
+        $stmt->addParameter(3, $owner ? 1 : 0);
+        $stmt->addParameter(4, $category);
+        $stmt->execute();
+    }
+
+    public function lockCategory(CommentsCategoryInfo|string $category): void {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->getId();
+
+        $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_locked = COALESCE(category_locked, NOW()) WHERE category_id = ?');
+        $stmt->addParameter(1, $category);
+        $stmt->execute();
+    }
+
+    public function unlockCategory(CommentsCategoryInfo|string $category): void {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->getId();
+
+        $stmt = $this->cache->get('UPDATE msz_comments_categories SET category_locked = NULL WHERE category_id = ?');
+        $stmt->addParameter(1, $category);
+        $stmt->execute();
+    }
+
+    public function countPosts(
+        CommentsCategoryInfo|string|null $category = null,
+        CommentsPostInfo|string|null $parent = null,
+        bool $includeReplies = false,
+        bool $includeDeleted = false
+    ): int {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->getId();
+        if($parent instanceof CommentsPostInfo)
+            $parent = $parent->getId();
+
+        $hasCategory = $category !== null;
+        $hasParent = $parent !== null;
+
+        $args = 0;
+        $query = 'SELECT COUNT(*) FROM msz_comments_posts';
+
+        if($hasParent) {
+            $query .= (++$args > 1 ? ' AND' : ' WHERE');
+            $query .= ' comment_reply_to = ?';
+        } else {
+            if($hasCategory) {
+                $query .= (++$args > 1 ? ' AND' : ' WHERE');
+                $query .= ' category_id = ?';
+            }
+
+            if(!$includeReplies) {
+                $query .= (++$args > 1 ? ' AND' : ' WHERE');
+                $query .= ' comment_reply_to IS NULL';
+            }
+        }
+
+        if(!$includeDeleted) {
+            $query .= (++$args > 1 ? ' AND' : ' WHERE');
+            $query .= ' comment_deleted IS NULL';
+        }
+
+        $args = 0;
+        $stmt = $this->cache->get($query);
+        if($hasParent)
+            $stmt->addParameter(++$args, $parent);
+        elseif($hasCategory)
+            $stmt->addParameter(++$args, $category);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        $count = 0;
+
+        if($result->next())
+            $count = $result->getInteger(0);
+
+        return $count;
+    }
+
+    public function getPosts(
+        CommentsCategoryInfo|string|null $category = null,
+        CommentsPostInfo|string|null $parent = null,
+        bool $includeReplies = false,
+        bool $includeDeleted = false,
+        bool $includeRepliesCount = false,
+        bool $includeVotesCount = false
+    ): array {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->getId();
+        if($parent instanceof CommentsPostInfo)
+            $parent = $parent->getId();
+
+        $hasCategory = $category !== null;
+        $hasParent = $parent !== null;
+
+        $args = 0;
+        $query = 'SELECT comment_id, category_id, user_id, comment_reply_to, comment_text, UNIX_TIMESTAMP(comment_created), UNIX_TIMESTAMP(comment_pinned), UNIX_TIMESTAMP(comment_edited), UNIX_TIMESTAMP(comment_deleted)';
+        if($includeRepliesCount)
+            $query .= ', (SELECT COUNT(*) FROM msz_comments_posts AS ccr WHERE ccr.comment_reply_to = cpp.comment_id AND comment_deleted IS NULL) AS `comment_replies`';
+        if($includeVotesCount) {
+            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id) AS `comment_votes_total`';
+            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote > 0) AS `comment_votes_positive`';
+            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote < 0) AS `comment_votes_negative`';
+        }
+        $query .= ' FROM msz_comments_posts AS cpp';
+
+        if($hasParent) {
+            $query .= (++$args > 1 ? ' AND' : ' WHERE');
+            $query .= ' comment_reply_to = ?';
+        } else {
+            if($hasCategory) {
+                $query .= (++$args > 1 ? ' AND' : ' WHERE');
+                $query .= ' category_id = ?';
+            }
+
+            if(!$includeReplies) {
+                $query .= (++$args > 1 ? ' AND' : ' WHERE');
+                $query .= ' comment_reply_to IS NULL';
+            }
+        }
+
+        if(!$includeDeleted) {
+            $query .= (++$args > 1 ? ' AND' : ' WHERE');
+            $query .= ' comment_deleted IS NULL';
+        }
+
+        // this should probably not be implicit like this
+        if($hasParent)
+            $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created ASC';
+        elseif($hasCategory)
+            $query .= ' ORDER BY comment_deleted ASC, comment_pinned DESC, comment_created DESC';
+        else
+            $query .= ' ORDER BY comment_created DESC';
+
+        $args = 0;
+        $stmt = $this->cache->get($query);
+        if($hasParent)
+            $stmt->addParameter(++$args, $parent);
+        elseif($hasCategory)
+            $stmt->addParameter(++$args, $category);
+        $stmt->execute();
+
+        $posts = [];
+        $result = $stmt->getResult();
+
+        while($result->next())
+            $posts[] = new CommentsPostInfo($result, $includeRepliesCount, $includeVotesCount);
+
+        return $posts;
+    }
+
+    public function getPostById(
+        string $postId,
+        bool $includeRepliesCount = false,
+        bool $includeVotesCount = false
+    ): CommentsPostInfo {
+        $query = 'SELECT comment_id, category_id, user_id, comment_reply_to, comment_text, UNIX_TIMESTAMP(comment_created), UNIX_TIMESTAMP(comment_pinned), UNIX_TIMESTAMP(comment_edited), UNIX_TIMESTAMP(comment_deleted)';
+        if($includeRepliesCount)
+            $query .= ', (SELECT COUNT(*) FROM msz_comments_posts AS ccr WHERE ccr.comment_reply_to = cpp.comment_id AND comment_deleted IS NULL) AS `comment_replies`';
+        if($includeVotesCount) {
+            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id) AS `comment_votes_total`';
+            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote > 0) AS `comment_votes_positive`';
+            $query .= ', (SELECT COUNT(*) FROM msz_comments_votes AS cvc WHERE cvc.comment_id = cpp.comment_id AND comment_vote < 0) AS `comment_votes_negative`';
+        }
+        $query .= ' FROM msz_comments_posts AS cpp WHERE comment_id = ?';
+
+        $stmt = $this->cache->get($query);
+        $stmt->addParameter(1, $postId);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('No comment with that ID exists.');
+
+        return new CommentsPostInfo($result, $includeRepliesCount, $includeVotesCount);
+    }
+
+    public function createPost(
+        CommentsCategoryInfo|string|null $category,
+        CommentsPostInfo|string|null $parent,
+        User|string|null $user,
+        string $body,
+        bool $pin = false
+    ): CommentsPostInfo {
+        if($category instanceof CommentsCategoryInfo)
+            $category = $category->getId();
+        if($parent instanceof CommentsPostInfo) {
+            if($category === null)
+                $category = $parent->getCategoryId();
+            elseif($category !== $parent->getCategoryId())
+                throw new InvalidArgumentException('$parent belongs to a different category than where this post is attempted to be created.');
+            $parent = $parent->getId();
+        }
+        if($category === null)
+            throw new InvalidArgumentException('$category is null; at least a $category or $parent must be specified.');
+        if($user instanceof User)
+            $user = (string)$user->getId();
+        if(empty(trim($body)))
+            throw new InvalidArgumentException('$body may not be empty.');
+
+        $stmt = $this->cache->get('INSERT INTO msz_comments_posts (category_id, user_id, comment_reply_to, comment_text, comment_pinned) VALUES (?, ?, ?, ?, IF(?, NOW(), NULL))');
+        $stmt->addParameter(1, $category);
+        $stmt->addParameter(2, $user);
+        $stmt->addParameter(3, $parent);
+        $stmt->addParameter(4, $body);
+        $stmt->addParameter(5, $pin ? 1 : 0);
+        $stmt->execute();
+
+        return $this->getPostById((string)$this->dbConn->getLastInsertId());
+    }
+
+    public function deletePost(CommentsPostInfo|string $infoOrId): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->getId();
+
+        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_deleted = COALESCE(comment_deleted, NOW()) WHERE comment_id = ?');
+        $stmt->addParameter(1, $infoOrId);
+        $stmt->execute();
+    }
+
+    public function nukePost(CommentsPostInfo|string $infoOrId): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->getId();
+
+        $stmt = $this->cache->get('DELETE FROM msz_comments_posts WHERE comment_id = ?');
+        $stmt->addParameter(1, $infoOrId);
+        $stmt->execute();
+    }
+
+    public function restorePost(CommentsPostInfo|string $infoOrId): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->getId();
+
+        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_deleted = NULL WHERE comment_id = ?');
+        $stmt->addParameter(1, $infoOrId);
+        $stmt->execute();
+    }
+
+    public function editPost(CommentsPostInfo|string $infoOrId, string $body): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->getId();
+
+        if(empty(trim($body)))
+            throw new InvalidArgumentException('$body may not be empty.');
+
+        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_text = ?, comment_edited = NOW() WHERE comment_id = ?');
+        $stmt->addParameter(1, $body);
+        $stmt->addParameter(2, $infoOrId);
+        $stmt->execute();
+    }
+
+    public function pinPost(CommentsPostInfo|string $infoOrId): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->getId();
+
+        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_pinned = COALESCE(comment_pinned, NOW()) WHERE comment_id = ?');
+        $stmt->addParameter(1, $infoOrId);
+        $stmt->execute();
+    }
+
+    public function unpinPost(CommentsPostInfo|string $infoOrId): void {
+        if($infoOrId instanceof CommentsPostInfo)
+            $infoOrId = $infoOrId->getId();
+
+        $stmt = $this->cache->get('UPDATE msz_comments_posts SET comment_pinned = NULL WHERE comment_id = ?');
+        $stmt->addParameter(1, $infoOrId);
+        $stmt->execute();
+    }
+
+    public function getPostVote(
+        CommentsPostInfo|string $post,
+        User|string|null $user
+    ): CommentsPostVoteInfo {
+        if($post instanceof CommentsPostInfo)
+            $post = $post->getId();
+        if($user instanceof User)
+            $user = (string)$user->getId();
+
+        // SUM() here makes it so a result row is always returned, albeit with just NULLs
+        $stmt = $this->cache->get('SELECT comment_id, user_id, SUM(comment_vote) FROM msz_comments_votes WHERE comment_id = ? AND user_id = ?');
+        $stmt->addParameter(1, $post);
+        $stmt->addParameter(2, $user);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('Failed to fetch vote info.');
+
+        return new CommentsPostVoteInfo($result);
+    }
+
+    public function addPostVote(
+        CommentsPostInfo|string $post,
+        User|string $user,
+        int $weight
+    ): void {
+        if($weight === 0)
+            return;
+        if($post instanceof CommentsPostInfo)
+            $post = $post->getId();
+        if($user instanceof User)
+            $user = (string)$user->getId();
+
+        $stmt = $this->cache->get('REPLACE INTO msz_comments_votes (comment_id, user_id, comment_vote) VALUES (?, ?, ?)');
+        $stmt->addParameter(1, $post);
+        $stmt->addParameter(2, $user);
+        $stmt->addParameter(3, $weight);
+        $stmt->execute();
+    }
+
+    public function addPostPositiveVote(CommentsPostInfo|string $post, User|string $user): void {
+        $this->addPostVote($post, $user, 1);
+    }
+
+    public function addPostNegativeVote(CommentsPostInfo|string $post, User|string $user): void {
+        $this->addPostVote($post, $user, -1);
+    }
+
+    public function removePostVote(
+        CommentsPostInfo|string $post,
+        User|string $user
+    ): void {
+        if($post instanceof CommentsPostInfo)
+            $post = $post->getId();
+        if($user instanceof User)
+            $user = (string)$user->getId();
+
+        $stmt = $this->cache->get('DELETE FROM msz_comments_votes WHERE comment_id = ? AND user_id = ?');
+        $stmt->addParameter(1, $post);
+        $stmt->addParameter(2, $user);
+        $stmt->execute();
+    }
+}
diff --git a/src/Comments/CommentsCategory.php b/src/Comments/CommentsCategory.php
deleted file mode 100644
index 165c01b2..00000000
--- a/src/Comments/CommentsCategory.php
+++ /dev/null
@@ -1,167 +0,0 @@
-<?php
-namespace Misuzu\Comments;
-
-use Misuzu\DB;
-use Misuzu\Memoizer;
-use Misuzu\Pagination;
-use Misuzu\Users\User;
-
-class CommentsCategoryException extends CommentsException {};
-class CommentsCategoryNotFoundException extends CommentsCategoryException {};
-
-class CommentsCategory {
-    // Database fields
-    private $category_id = -1;
-    private $category_name = '';
-    private $owner_id = null;
-    private $category_created = null;
-    private $category_locked = null;
-
-    private $postCount = -1;
-    private $owner = null;
-
-    public const TABLE = 'comments_categories';
-    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
-    private const SELECT = '%1$s.`category_id`, %1$s.`category_name`, %1$s.`owner_id`'
-        . ', UNIX_TIMESTAMP(%1$s.`category_created`) AS `category_created`'
-        . ', UNIX_TIMESTAMP(%1$s.`category_locked`) AS `category_locked`';
-
-    public function __construct(?string $name = null) {
-        if($name !== null)
-            $this->setName($name);
-    }
-
-    public function getId(): int {
-        return $this->category_id < 1 ? -1 : $this->category_id;
-    }
-
-    public function getName(): string {
-        return $this->category_name;
-    }
-    public function setName(string $name): self {
-        $this->category_name = $name;
-        return $this;
-    }
-
-    public function getOwnerId(): int {
-        return $this->owner_id < 1 ? -1 : $this->owner_id;
-    }
-    public function hasOwner(): bool {
-        return $this->owner_id !== null;
-    }
-    public function getOwner(): User {
-        if($this->owner === null && ($ownerId = $this->getOwnerId()) >= 1)
-            $this->owner = User::byId($ownerId);
-        return $this->owner;
-    }
-    public function isOwner(User $user): bool {
-        return $this->hasOwner() && $user->getId() === $this->getOwnerId();
-    }
-
-    public function getCreatedTime(): int {
-        return $this->category_created === null ? -1 : $this->category_created;
-    }
-
-    public function getLockedTime(): int {
-        return $this->category_locked === null ? -1 : $this->category_locked;
-    }
-    public function isLocked(): bool {
-        return $this->getLockedTime() >= 0;
-    }
-    public function setLocked(bool $locked): self {
-        if($locked !== $this->isLocked())
-            $this->category_locked = $locked ? time() : null;
-        return $this;
-    }
-
-    // Purely cosmetic, do not use for anything other than displaying
-    public function getPostCount(): int {
-        if($this->postCount < 0)
-            $this->postCount = (int)DB::prepare('
-                SELECT COUNT(`comment_id`)
-                FROM `msz_comments_posts`
-                WHERE `category_id` = :cat_id
-                AND `comment_deleted` IS NULL
-            ')->bind('cat_id', $this->getId())->fetchColumn();
-
-        return $this->postCount;
-    }
-
-    public function save(): void {
-        $isInsert = $this->getId() < 1;
-        if($isInsert) {
-            $query = 'INSERT INTO `%1$s%2$s` (`category_name`, `category_locked`) VALUES'
-                . ' (:name, :locked)';
-        } else {
-            $query = 'UPDATE `%1$s%2$s` SET `category_name` = :name, `category_locked` = FROM_UNIXTIME(:locked)'
-                . ' WHERE `category_id` = :category';
-        }
-
-        $saveCategory = DB::prepare(sprintf($query, DB::PREFIX, self::TABLE))
-            ->bind('name', $this->category_name)
-            ->bind('locked', $this->category_locked);
-
-        if($isInsert) {
-            $this->category_id = $saveCategory->executeGetId();
-            $this->category_created = time();
-        } else {
-            $saveCategory->bind('category', $this->getId())
-                ->execute();
-        }
-    }
-
-    public function posts(?User $voteUser = null, bool $includeVotes = true, ?Pagination $pagination = null, bool $rootOnly = true, bool $includeDeleted = true): array {
-        return CommentsPost::byCategory($this, $voteUser, $includeVotes, $pagination, $rootOnly, $includeDeleted);
-    }
-    public function votes(?User $user = null, bool $rootOnly = true, ?Pagination $pagination = null): array {
-        return CommentsVote::byCategory($this, $user, $rootOnly, $pagination);
-    }
-
-    private static function memoizer() {
-        static $memoizer = null;
-        if($memoizer === null)
-            $memoizer = new Memoizer;
-        return $memoizer;
-    }
-
-    private static function byQueryBase(): string {
-        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
-    }
-    public static function byId(int $categoryId): self {
-        return self::memoizer()->find($categoryId, function() use ($categoryId) {
-            $cat = DB::prepare(self::byQueryBase() . ' WHERE `category_id` = :cat_id')
-                ->bind('cat_id', $categoryId)
-                ->fetchObject(self::class);
-            if(!$cat)
-                throw new CommentsCategoryNotFoundException;
-            return $cat;
-        });
-    }
-    public static function byName(string $categoryName): self {
-        return self::memoizer()->find(function($category) use ($categoryName) {
-            return $category->getName() === $categoryName;
-        }, function() use ($categoryName) {
-            $cat = DB::prepare(self::byQueryBase() . ' WHERE `category_name` = :name')
-                ->bind('name', $categoryName)
-                ->fetchObject(self::class);
-            if(!$cat)
-                throw new CommentsCategoryNotFoundException;
-            return $cat;
-        });
-    }
-    public static function all(?Pagination $pagination = null): array {
-        $catsQuery = self::byQueryBase()
-            . ' ORDER BY `category_id` ASC';
-
-        if($pagination !== null)
-            $catsQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getCats = DB::prepare($catsQuery);
-
-        if($pagination !== null)
-            $getCats->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getCats->fetchObjects(self::class);
-    }
-}
diff --git a/src/Comments/CommentsCategoryInfo.php b/src/Comments/CommentsCategoryInfo.php
new file mode 100644
index 00000000..71631d90
--- /dev/null
+++ b/src/Comments/CommentsCategoryInfo.php
@@ -0,0 +1,72 @@
+<?php
+namespace Misuzu\Comments;
+
+use Index\DateTime;
+use Index\Data\IDbResult;
+use Misuzu\Users\User;
+
+class CommentsCategoryInfo {
+    private string $id;
+    private string $name;
+    private ?string $ownerId;
+    private int $created;
+    private ?int $locked;
+    private int $comments;
+
+    public function __construct(IDbResult $result) {
+        $this->id = (string)$result->getInteger(0);
+        $this->name = $result->getString(1);
+        $this->ownerId = $result->isNull(2) ? null : (string)$result->getInteger(2);
+        $this->created = $result->getInteger(3);
+        $this->locked = $result->isNull(4) ? null : $result->getInteger(4);
+        $this->comments = $result->getInteger(5);
+    }
+
+    public function getId(): string {
+        return $this->id;
+    }
+
+    public function getName(): string {
+        return $this->name;
+    }
+
+    public function hasOwnerId(): bool {
+        return $this->ownerId !== null;
+    }
+
+    public function getOwnerId(): ?string {
+        return $this->ownerId;
+    }
+
+    public function isOwner(User|string $user): bool {
+        if($this->ownerId === null)
+            return false;
+        if($user instanceof User)
+            $user = (string)$user->getId();
+        return $user === $this->ownerId;
+    }
+
+    public function getCreatedTime(): int {
+        return $this->created;
+    }
+
+    public function getCreatedAt(): DateTime {
+        return DateTime::fromUnixTimeSeconds($this->created);
+    }
+
+    public function getLockedTime(): ?int {
+        return $this->locked;
+    }
+
+    public function getLockedAt(): ?DateTime {
+        return $this->locked === null ? null : DateTime::fromUnixTimeSeconds($this->locked);
+    }
+
+    public function isLocked(): bool {
+        return $this->locked !== null;
+    }
+
+    public function getCommentsCount(): int {
+        return $this->comments;
+    }
+}
diff --git a/src/Comments/CommentsEx.php b/src/Comments/CommentsEx.php
new file mode 100644
index 00000000..ff8dcdb2
--- /dev/null
+++ b/src/Comments/CommentsEx.php
@@ -0,0 +1,61 @@
+<?php
+namespace Misuzu\Comments;
+
+use stdClass;
+use Misuzu\Users\User;
+use Misuzu\Users\UserNotFoundException;
+
+class CommentsEx {
+    private Comments $comments;
+    private array $userInfos;
+
+    public function __construct(Comments $comments, array $userInfos = []) {
+        $this->comments = $comments;
+        $this->userInfos = $userInfos;
+    }
+
+    public function getCommentsForLayout(CommentsCategoryInfo|string $category): object {
+        $info = new stdClass;
+        if(is_string($category))
+            $category = $this->comments->ensureCategory($category);
+
+        $info->user = User::getCurrent();
+        $info->category = $category;
+        $info->posts = [];
+
+        $root = $this->comments->getPosts($category, includeRepliesCount: true, includeVotesCount: true, includeDeleted: true);
+        foreach($root as $postInfo)
+            $info->posts[] = $this->decorateComment($postInfo);
+
+        return $info;
+    }
+
+    public function decorateComment(CommentsPostInfo $postInfo): object {
+        if($postInfo->hasUserId()) {
+            $userId = $postInfo->getUserId();
+            if(array_key_exists($userId, $this->userInfos)) {
+                $userInfo = $this->userInfos[$userId];
+            } else {
+                try {
+                    $userInfo = User::byId($userId);
+                } catch(UserNotFoundException $ex) {
+                    $userInfo = null;
+                }
+
+                $this->userInfos[$userId] = $userInfo;
+            }
+        } else $userInfo = null;
+
+        $info = new stdClass;
+        $info->post = $postInfo;
+        $info->user = $userInfo;
+        $info->vote = $this->comments->getPostVote($postInfo, $userInfo);
+        $info->replies = [];
+
+        $root = $this->comments->getPosts(parent: $postInfo, includeRepliesCount: true, includeVotesCount: true, includeDeleted: true);
+        foreach($root as $childInfo)
+            $info->replies[] = $this->decorateComment($childInfo);
+
+        return $info;
+    }
+}
diff --git a/src/Comments/CommentsException.php b/src/Comments/CommentsException.php
deleted file mode 100644
index adaa76dc..00000000
--- a/src/Comments/CommentsException.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-namespace Misuzu\Comments;
-
-use RuntimeException;
-
-class CommentsException extends RuntimeException {}
diff --git a/src/Comments/CommentsPost.php b/src/Comments/CommentsPost.php
deleted file mode 100644
index 6b827109..00000000
--- a/src/Comments/CommentsPost.php
+++ /dev/null
@@ -1,326 +0,0 @@
-<?php
-namespace Misuzu\Comments;
-
-use Misuzu\DB;
-use Misuzu\Pagination;
-use Misuzu\Users\User;
-use Misuzu\Users\UserNotFoundException;
-
-class CommentsPostException extends CommentsException {}
-class CommentsPostNotFoundException extends CommentsPostException {}
-class CommentsPostHasNoParentException extends CommentsPostException {}
-class CommentsPostSaveFailedException extends CommentsPostException {}
-
-class CommentsPost {
-    // Database fields
-    private $comment_id = -1;
-    private $category_id = -1;
-    private $user_id = null;
-    private $comment_reply_to = null;
-    private $comment_text = '';
-    private $comment_created = null;
-    private $comment_pinned = null;
-    private $comment_edited = null;
-    private $comment_deleted = null;
-
-    // Virtual fields
-    private $comment_likes = -1;
-    private $comment_dislikes = -1;
-    private $user_vote = null;
-
-    private $category = null;
-    private $user = null;
-    private $userLookedUp = false;
-    private $parentPost = null;
-
-    public const TABLE = 'comments_posts';
-    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
-    private const SELECT = '%1$s.`comment_id`, %1$s.`category_id`, %1$s.`user_id`, %1$s.`comment_reply_to`, %1$s.`comment_text`'
-        . ', UNIX_TIMESTAMP(%1$s.`comment_created`) AS `comment_created`'
-        . ', UNIX_TIMESTAMP(%1$s.`comment_pinned`) AS `comment_pinned`'
-        . ', UNIX_TIMESTAMP(%1$s.`comment_edited`) AS `comment_edited`'
-        . ', UNIX_TIMESTAMP(%1$s.`comment_deleted`) AS `comment_deleted`';
-    private const LIKE_VOTE_SELECT    = '(SELECT COUNT(`comment_id`) FROM `' . DB::PREFIX . CommentsVote::TABLE . '` WHERE `comment_id` = %1$s.`comment_id` AND `comment_vote` = ' . CommentsVote::LIKE . ') AS `comment_likes`';
-    private const DISLIKE_VOTE_SELECT = '(SELECT COUNT(`comment_id`) FROM `' . DB::PREFIX . CommentsVote::TABLE . '` WHERE `comment_id` = %1$s.`comment_id` AND `comment_vote` = ' . CommentsVote::DISLIKE . ') AS `comment_dislikes`';
-    private const USER_VOTE_SELECT    = '(SELECT `comment_vote` FROM `' . DB::PREFIX . CommentsVote::TABLE . '` WHERE `comment_id` = %1$s.`comment_id` AND `user_id` = :user) AS `user_vote`';
-
-    public function getId(): int {
-        return $this->comment_id < 1 ? -1 : $this->comment_id;
-    }
-
-    public function getCategoryId(): int {
-        return $this->category_id < 1 ? -1 : $this->category_id;
-    }
-    public function setCategoryId(int $categoryId): self {
-        $this->category_id = $categoryId;
-        $this->category = null;
-        return $this;
-    }
-    public function getCategory(): CommentsCategory {
-        if($this->category === null)
-            $this->category = CommentsCategory::byId($this->getCategoryId());
-        return $this->category;
-    }
-    public function setCategory(CommentsCategory $category): self {
-        $this->category_id = $category->getId();
-        $this->category = null;
-        return $this;
-    }
-
-    public function getUserId(): int {
-        return $this->user_id < 1 ? -1 : $this->user_id;
-    }
-    public function setUserId(int $userId): self {
-        $this->user_id = $userId < 1 ? null : $userId;
-        $this->userLookedUp = false;
-        $this->user = null;
-        return $this;
-    }
-    public function getUser(): ?User {
-        if(!$this->userLookedUp && ($userId = $this->getUserId()) > 0) {
-            $this->userLookedUp = true;
-            try {
-                $this->user = User::byId($userId);
-            } catch(UserNotFoundException $ex) {}
-        }
-        return $this->user;
-    }
-    public function setUser(?User $user): self {
-        $this->user_id = $user === null ? null : $user->getId();
-        $this->userLookedUp = true;
-        $this->user = $user;
-        return $this;
-    }
-
-    public function getParentId(): int {
-        return $this->comment_reply_to < 1 ? -1 : $this->comment_reply_to;
-    }
-    public function setParentId(int $parentId): self {
-        $this->comment_reply_to = $parentId < 1 ? null : $parentId;
-        $this->parentPost = null;
-        return $this;
-    }
-    public function hasParent(): bool {
-        return $this->getParentId() > 0;
-    }
-    public function getParent(): CommentsPost {
-        if(!$this->hasParent())
-            throw new CommentsPostHasNoParentException;
-        if($this->parentPost === null)
-            $this->parentPost = CommentsPost::byId($this->getParentId());
-        return $this->parentPost;
-    }
-    public function setParent(?CommentsPost $parent): self {
-        $this->comment_reply_to = $parent === null ? null : $parent->getId();
-        $this->parentPost = $parent;
-        return $this;
-    }
-
-    public function getText(): string {
-        return $this->comment_text;
-    }
-    public function setText(string $text): self {
-        $this->comment_text = $text;
-        return $this;
-    }
-    public function getParsedText(): string {
-        return CommentsParser::parseForDisplay($this->getText());
-    }
-    public function setParsedText(string $text): self {
-        return $this->setText(CommentsParser::parseForStorage($text));
-    }
-
-    public function getCreatedTime(): int {
-        return $this->comment_created === null ? -1 : $this->comment_created;
-    }
-
-    public function getPinnedTime(): int {
-        return $this->comment_pinned === null ? -1 : $this->comment_pinned;
-    }
-    public function isPinned(): bool {
-        return $this->getPinnedTime() >= 0;
-    }
-    public function setPinned(bool $pinned): self {
-        if($this->isPinned() !== $pinned)
-            $this->comment_pinned = $pinned ? time() : null;
-        return $this;
-    }
-
-    public function getEditedTime(): int {
-        return $this->comment_edited === null ? -1 : $this->comment_edited;
-    }
-    public function isEdited(): bool {
-        return $this->getEditedTime() >= 0;
-    }
-
-    public function getDeletedTime(): int {
-        return $this->comment_deleted === null ? -1 : $this->comment_deleted;
-    }
-    public function isDeleted(): bool {
-        return $this->getDeletedTime() >= 0;
-    }
-    public function setDeleted(bool $deleted): self {
-        if($this->isDeleted() !== $deleted)
-            $this->comment_deleted = $deleted ? time() : null;
-        return $this;
-    }
-
-    public function getLikes(): int {
-        return $this->comment_likes;
-    }
-    public function getDislikes(): int {
-        return $this->comment_dislikes;
-    }
-
-    public function hasUserVote(): bool {
-        return $this->user_vote !== null;
-    }
-    public function getUserVote(): int {
-        return $this->user_vote ?? 0;
-    }
-
-    public function save(): void {
-        $isInsert = $this->getId() < 1;
-        if($isInsert) {
-            $query = 'INSERT INTO `%1$s%2$s` (`category_id`, `user_id`, `comment_reply_to`, `comment_text`'
-                . ', `comment_pinned`, `comment_deleted`) VALUES'
-                . ' (:category, :user, :parent, :text, FROM_UNIXTIME(:pinned), FROM_UNIXTIME(:deleted))';
-        } else {
-            $query = 'UPDATE `%1$s%2$s` SET `category_id` = :category, `user_id` = :user, `comment_reply_to` = :parent'
-                . ', `comment_text` = :text, `comment_pinned` = FROM_UNIXTIME(:pinned), `comment_deleted` = FROM_UNIXTIME(:deleted)'
-                . ' WHERE `comment_id` = :post';
-        }
-
-        $savePost = DB::prepare(sprintf($query, DB::PREFIX, self::TABLE))
-            ->bind('category', $this->category_id)
-            ->bind('user', $this->user_id)
-            ->bind('parent', $this->comment_reply_to)
-            ->bind('text', $this->comment_text)
-            ->bind('pinned', $this->comment_pinned)
-            ->bind('deleted', $this->comment_deleted);
-
-        if($isInsert) {
-            $this->comment_id = $savePost->executeGetId();
-            if($this->comment_id < 1)
-                throw new CommentsPostSaveFailedException;
-            $this->comment_created = time();
-        } else {
-            $this->comment_edited = time();
-            $savePost->bind('post', $this->getId());
-            if(!$savePost->execute())
-                throw new CommentsPostSaveFailedException;
-        }
-    }
-
-    public function nuke(): void {
-        $replies = $this->replies(null, true);
-        foreach($replies as $reply)
-            $reply->nuke();
-        DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `comment_id` = :comment')
-            ->bind('comment_id', $this->getId())
-            ->execute();
-    }
-
-    public function replies(?User $voteUser = null, bool $includeVotes = true, ?Pagination $pagination = null, bool $includeDeleted = true): array {
-        return CommentsPost::byParent($this, $voteUser, $includeVotes, $pagination, $includeDeleted);
-    }
-    public function votes(): CommentsVoteCount {
-        return CommentsVote::countByPost($this);
-    }
-    public function childVotes(?User $user = null, ?Pagination $pagination = null): array {
-        return CommentsVote::byParent($this, $user, $pagination);
-    }
-
-    public function addPositiveVote(User $user): void {
-        CommentsVote::create($this, $user, CommentsVote::LIKE);
-    }
-    public function addNegativeVote(User $user): void {
-        CommentsVote::create($this, $user, CommentsVote::DISLIKE);
-    }
-    public function removeVote(User $user): void {
-        CommentsVote::delete($this, $user);
-    }
-
-    public function getVoteFromUser(User $user): CommentsVote {
-        return CommentsVote::byExact($this, $user);
-    }
-
-    private static function byQueryBase(bool $includeVotes = true, bool $includeUserVote = false): string {
-        $select = self::SELECT;
-        if($includeVotes)
-            $select .= ', ' . self::LIKE_VOTE_SELECT
-                    .  ', ' . self::DISLIKE_VOTE_SELECT;
-        if($includeUserVote)
-            $select .= ', ' . self::USER_VOTE_SELECT;
-        return sprintf(self::QUERY_SELECT, sprintf($select, self::TABLE));
-    }
-    public static function byId(int $postId): self {
-        $getPost = DB::prepare(self::byQueryBase() . ' WHERE `comment_id` = :post_id');
-        $getPost->bind('post_id', $postId);
-        $post = $getPost->fetchObject(self::class);
-        if(!$post)
-            throw new CommentsPostNotFoundException;
-        return $post;
-    }
-    public static function byCategory(CommentsCategory $category, ?User $voteUser = null, bool $includeVotes = true, ?Pagination $pagination = null, bool $rootOnly = true, bool $includeDeleted = true): array {
-        $postsQuery = self::byQueryBase($includeVotes, $voteUser !== null)
-            . ' WHERE `category_id` = :category'
-            . (!$rootOnly      ? '' : ' AND `comment_reply_to` IS NULL')
-            . ($includeDeleted ? '' : ' AND `comment_deleted`  IS NULL')
-            . ' ORDER BY `comment_deleted` ASC, `comment_pinned` DESC, `comment_id` DESC';
-
-        if($pagination !== null)
-            $postsQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getPosts = DB::prepare($postsQuery)
-            ->bind('category', $category->getId());
-
-        if($voteUser !== null)
-            $getPosts->bind('user', $voteUser->getId());
-
-        if($pagination !== null)
-            $getPosts->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getPosts->fetchObjects(self::class);
-    }
-    public static function byParent(CommentsPost $parent, ?User $voteUser = null, bool $includeVotes = true, ?Pagination $pagination = null, bool $includeDeleted = true): array {
-        $postsQuery = self::byQueryBase($includeVotes, $voteUser !== null)
-            . ' WHERE `comment_reply_to` = :parent'
-            . ($includeDeleted ? '' : ' AND `comment_deleted` IS NULL')
-            . ' ORDER BY `comment_deleted` ASC, `comment_pinned` DESC, `comment_id` ASC';
-
-        if($pagination !== null)
-            $postsQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getPosts = DB::prepare($postsQuery)
-            ->bind('parent', $parent->getId());
-
-        if($voteUser !== null)
-            $getPosts->bind('user', $voteUser->getId());
-
-        if($pagination !== null)
-            $getPosts->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getPosts->fetchObjects(self::class);
-    }
-    public static function all(?Pagination $pagination = null, bool $rootOnly = true, bool $includeDeleted = false): array {
-        $postsQuery = self::byQueryBase()
-            . ' WHERE 1' // this is disgusting
-            . (!$rootOnly      ? '' : ' AND `comment_reply_to` IS NULL')
-            . ($includeDeleted ? '' : ' AND `comment_deleted`  IS NULL')
-            . ' ORDER BY `comment_id` DESC';
-
-        if($pagination !== null)
-            $postsQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getPosts = DB::prepare($postsQuery);
-
-        if($pagination !== null)
-            $getPosts->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getPosts->fetchObjects(self::class);
-    }
-}
diff --git a/src/Comments/CommentsPostInfo.php b/src/Comments/CommentsPostInfo.php
new file mode 100644
index 00000000..51a3f7c0
--- /dev/null
+++ b/src/Comments/CommentsPostInfo.php
@@ -0,0 +1,146 @@
+<?php
+namespace Misuzu\Comments;
+
+use Index\DateTime;
+use Index\Data\IDbResult;
+
+class CommentsPostInfo {
+    private string $id;
+    private string $categoryId;
+    private ?string $userId;
+    private ?string $replyingTo;
+    private string $body;
+    private int $created;
+    private ?int $pinned;
+    private ?int $updated;
+    private ?int $deleted;
+    private int $replies;
+    private int $votesTotal;
+    private int $votesPositive;
+    private int $votesNegative;
+
+    public function __construct(
+        IDbResult $result,
+        bool $includeRepliesCount = false,
+        bool $includeVotesCount = false
+    ) {
+        $args = 0;
+        $this->id = (string)$result->getInteger($args);
+        $this->categoryId = (string)$result->getInteger(++$args);
+        $this->userId = $result->isNull(++$args) ? null : (string)$result->getInteger($args);
+        $this->replyingTo = $result->isNull(++$args) ? null : (string)$result->getInteger($args);
+        $this->body = $result->getString(++$args);
+        $this->created = $result->getInteger(++$args);
+        $this->pinned = $result->isNull(++$args) ? null : $result->getInteger($args);
+        $this->updated = $result->isNull(++$args) ? null : $result->getInteger($args);
+        $this->deleted = $result->isNull(++$args) ? null : $result->getInteger($args);
+
+        $this->replies = $includeRepliesCount ? $result->getInteger(++$args) : 0;
+
+        if($includeVotesCount) {
+            $this->votesTotal = $result->getInteger(++$args);
+            $this->votesPositive = $result->getInteger(++$args);
+            $this->votesNegative = $result->getInteger(++$args);
+        } else {
+            $this->votesTotal = 0;
+            $this->votesPositive = 0;
+            $this->votesNegative = 0;
+        }
+    }
+
+    public function getId(): string {
+        return $this->id;
+    }
+
+    public function getCategoryId(): string {
+        return $this->categoryId;
+    }
+
+    public function hasUserId(): bool {
+        return $this->userId !== null;
+    }
+
+    public function getUserId(): ?string {
+        return $this->userId;
+    }
+
+    public function isReply(): bool {
+        return $this->replyingTo !== null;
+    }
+
+    public function getReplyingTo(): ?string {
+        return $this->replyingTo;
+    }
+
+    public function getBody(): string {
+        return $this->body;
+    }
+
+    public function getCreatedTime(): int {
+        return $this->created;
+    }
+
+    public function getCreatedAt(): DateTime {
+        return DateTime::fromUnixTimeSeconds($this->created);
+    }
+
+    public function getPinnedTime(): ?int {
+        return $this->pinned;
+    }
+
+    public function getPinnedAt(): DateTime {
+        return $this->pinned === null ? null : DateTime::fromUnixTimeSeconds($this->pinned);
+    }
+
+    public function isPinned(): bool {
+        return $this->pinned !== null;
+    }
+
+    public function getUpdatedTime(): ?int {
+        return $this->updated;
+    }
+
+    public function getUpdatedAt(): DateTime {
+        return $this->updated === null ? null : DateTime::fromUnixTimeSeconds($this->updated);
+    }
+
+    public function isEdited(): bool {
+        return $this->updated !== null;
+    }
+
+    public function getDeletedTime(): ?int {
+        return $this->deleted;
+    }
+
+    public function getDeletedAt(): DateTime {
+        return $this->deleted === null ? null : DateTime::fromUnixTimeSeconds($this->deleted);
+    }
+
+    public function isDeleted(): bool {
+        return $this->deleted !== null;
+    }
+
+    public function hasRepliesCount(): bool {
+        return $this->replies > 0;
+    }
+
+    public function getRepliesCount(): int {
+        return $this->replies;
+    }
+
+    public function hasVotesCount(): bool {
+        return $this->votesTotal > 0;
+    }
+
+    public function getVotesTotal(): int {
+        return $this->votesTotal;
+    }
+
+    public function getVotesPositive(): int {
+        return $this->votesPositive;
+    }
+
+    public function getVotesNegative(): int {
+        return $this->votesNegative;
+    }
+}
diff --git a/src/Comments/CommentsPostVoteInfo.php b/src/Comments/CommentsPostVoteInfo.php
new file mode 100644
index 00000000..4a48a458
--- /dev/null
+++ b/src/Comments/CommentsPostVoteInfo.php
@@ -0,0 +1,28 @@
+<?php
+namespace Misuzu\Comments;
+
+use Index\Data\IDbResult;
+
+class CommentsPostVoteInfo {
+    private string $commentId;
+    private string $userId;
+    private int $weight;
+
+    public function __construct(IDbResult $result) {
+        $this->commentId = (string)$result->getInteger(0);
+        $this->userId = (string)$result->getInteger(1);
+        $this->weight = $result->getInteger(2);
+    }
+
+    public function getCommentId(): string {
+        return $this->commentId;
+    }
+
+    public function getUserId(): string {
+        return $this->userId;
+    }
+
+    public function getWeight(): int {
+        return $this->weight;
+    }
+}
diff --git a/src/Comments/CommentsVote.php b/src/Comments/CommentsVote.php
deleted file mode 100644
index 947e03d3..00000000
--- a/src/Comments/CommentsVote.php
+++ /dev/null
@@ -1,228 +0,0 @@
-<?php
-namespace Misuzu\Comments;
-
-use Misuzu\DB;
-use Misuzu\Pagination;
-use Misuzu\Users\User;
-
-class CommentsVoteException extends CommentsException {}
-class CommentsVoteCountFailedException extends CommentsVoteException {}
-class CommentsVoteCreateFailedException extends CommentsVoteException {}
-
-class CommentsVoteCount {
-    private $comment_id = -1;
-    private $likes = 0;
-    private $dislikes = 0;
-    private $total = 0;
-
-    public function getPostId(): int {
-        return $this->comment_id < 1 ? -1 : $this->comment_id;
-    }
-    public function getLikes(): int {
-        return $this->likes;
-    }
-    public function getDislikes(): int {
-        return $this->dislikes;
-    }
-    public function getTotal(): int {
-        return $this->total;
-    }
-}
-
-class CommentsVote {
-    // Database fields
-    private $comment_id = -1;
-    private $user_id = -1;
-    private $comment_vote = 0;
-
-    private $comment = null;
-    private $user = null;
-
-    public const LIKE    =  1;
-    public const NONE    =  0;
-    public const DISLIKE = -1;
-
-    public const TABLE = 'comments_votes';
-    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
-    private const SELECT = '%1$s.`comment_id`, %1$s.`user_id`, %1$s.`comment_vote`';
-
-    private const QUERY_COUNT = 'SELECT %3$d AS `comment_id`'
-        . ', (SELECT COUNT(`comment_id`) FROM `%1$s%2$s` WHERE %6$s) AS `total`'
-        . ', (SELECT COUNT(`comment_id`) FROM `%1$s%2$s` WHERE %6$s AND `comment_vote` = %4$d) AS `likes`'
-        . ', (SELECT COUNT(`comment_id`) FROM `%1$s%2$s` WHERE %6$s AND `comment_vote` = %5$d) AS `dislikes`';
-
-    public function getPostId(): int {
-        return $this->comment_id < 1 ? -1 : $this->comment_id;
-    }
-    public function getPost(): CommentsPost {
-        if($this->comment === null)
-            $this->comment = CommentsPost::byId($this->comment_id);
-        return $this->comment;
-    }
-
-    public function getUserId(): int {
-        return $this->user_id < 1 ? -1 : $this->user_id;
-    }
-    public function getUser(): User {
-        if($this->user === null)
-            $this->user = User::byId($this->user_id);
-        return $this->user;
-    }
-
-    public function getVote(): int {
-        return $this->comment_vote;
-    }
-
-    public static function create(CommentsPost $post, User $user, int $vote, bool $return = false): ?self {
-        $createVote = DB::prepare('
-            REPLACE INTO `msz_comments_votes`
-                (`comment_id`, `user_id`, `comment_vote`)
-            VALUES
-                (:post, :user, :vote)
-        ')  ->bind('post', $post->getId())
-            ->bind('user', $user->getId())
-            ->bind('vote', $vote);
-
-        if(!$createVote->execute())
-            throw new CommentsVoteCreateFailedException;
-        if(!$return)
-            return null;
-
-        return self::byExact($post, $user);
-    }
-
-    public static function delete(CommentsPost $post, User $user): void {
-        DB::prepare('DELETE FROM `msz_comments_votes` WHERE `comment_id` = :post AND `user_id` = :user')
-            ->bind('post', $post->getId())
-            ->bind('user', $user->getId())
-            ->execute();
-    }
-
-    private static function countQueryBase(int $id, string $condition = '1'): string {
-        return sprintf(self::QUERY_COUNT, DB::PREFIX, self::TABLE, $id, self::LIKE, self::DISLIKE, $condition);
-    }
-    public static function countByPost(CommentsPost $post): CommentsVoteCount {
-        $count = DB::prepare(self::countQueryBase($post->getId(), sprintf('`comment_id` = %d', $post->getId())))
-            ->fetchObject(CommentsVoteCount::class);
-        if(!$count)
-            throw new CommentsVoteCountFailedException;
-        return $count;
-    }
-
-    private static function fake(CommentsPost $post, User $user, int $vote): CommentsVote {
-        $fake = new CommentsVote;
-        $fake->comment_id = $post->getId();
-        $fake->comment = $post;
-        $fake->user_id = $user->getId();
-        $fake->user = $user;
-        $fake->comment_vote = $vote;
-        return $fake;
-    }
-
-    private static function byQueryBase(): string {
-        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
-    }
-    public static function byExact(CommentsPost $post, User $user): self {
-        $vote = DB::prepare(self::byQueryBase() . ' WHERE `comment_id` = :post_id AND `user_id` = :user_id')
-            ->bind('post_id', $post->getId())
-            ->bind('user_id', $user->getId())
-            ->fetchObject(self::class);
-        if(!$vote)
-            return self::fake($post, $user, self::NONE);
-        return $vote;
-    }
-    public static function byPost(CommentsPost $post, ?User $user = null, ?Pagination $pagination = null): array {
-        $votesQuery = self::byQueryBase()
-            . ' WHERE `comment_id` = :post'
-            . ($user === null ? '' : ' AND `user_id` = :user');
-
-        if($pagination !== null)
-            $votesQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getVotes = DB::prepare($votesQuery)
-            ->bind('post', $post->getId());
-
-        if($user !== null)
-            $getVotes->bind('user', $user->getId());
-
-        if($pagination !== null)
-            $getVotes->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getVotes->fetchObjects(self::class);
-    }
-    public static function byUser(User $user, ?Pagination $pagination = null): array {
-        $votesQuery = self::byQueryBase()
-            . ' WHERE `user_id` = :user';
-
-        if($pagination !== null)
-            $votesQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getVotes = DB::prepare($votesQuery)
-            ->bind('user', $user->getId());
-
-        if($pagination !== null)
-            $getVotes->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getVotes->fetchObjects(self::class);
-    }
-    public static function byCategory(CommentsCategory $category, ?User $user = null, bool $rootOnly = true, ?Pagination $pagination = null): array {
-        $votesQuery = self::byQueryBase()
-            . ' WHERE `comment_id` IN'
-            . ' (SELECT `comment_id` FROM `' . DB::PREFIX . CommentsPost::TABLE . '` WHERE `category_id` = :category'
-            . (!$rootOnly ? '' : ' AND `comment_reply_to` IS NULL')
-            . ')'
-            . ($user === null ? '' : ' AND `user_id` = :user');
-
-        if($pagination !== null)
-            $votesQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getVotes = DB::prepare($votesQuery)
-            ->bind('category', $category->getId());
-
-        if($user !== null)
-            $getVotes->bind('user', $user->getId());
-
-        if($pagination !== null)
-            $getVotes->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getVotes->fetchObjects(self::class);
-    }
-    public static function byParent(CommentsPost $parent, ?User $user = null, ?Pagination $pagination = null): array {
-        $votesQuery = self::byQueryBase()
-            . ' WHERE `comment_id` IN'
-            . ' (SELECT `comment_id` FROM `' . DB::PREFIX . CommentsPost::TABLE . '` WHERE `comment_reply_to` = :parent)'
-            . ($user === null ? '' : ' AND `user_id` = :user');
-
-        if($pagination !== null)
-            $votesQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getVotes = DB::prepare($votesQuery)
-            ->bind('parent', $parent->getId());
-
-        if($user !== null)
-            $getVotes->bind('user', $user->getId());
-
-        if($pagination !== null)
-            $getVotes->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getVotes->fetchObjects(self::class);
-    }
-    public static function all(?Pagination $pagination = null): array {
-        $votesQuery = self::byQueryBase();
-
-        if($pagination !== null)
-            $votesQuery .= ' LIMIT :range OFFSET :offset';
-
-        $getVotes = DB::prepare($votesQuery);
-
-        if($pagination !== null)
-            $getVotes->bind('range', $pagination->getRange())
-                ->bind('offset', $pagination->getOffset());
-
-        return $getVotes->fetchObjects(self::class);
-    }
-}
diff --git a/src/Database/Database.php b/src/Database/Database.php
index c86b54e1..8928df81 100644
--- a/src/Database/Database.php
+++ b/src/Database/Database.php
@@ -12,7 +12,7 @@ class Database {
     }
 
     public function queries(): int {
-        return (int)$this->query('SHOW SESSION STATUS LIKE "Questions"')->fetchColumn(1);
+        return ((int)$this->query('SHOW SESSION STATUS LIKE "Questions"')->fetchColumn(1));
     }
 
     public function exec(string $stmt): int {
diff --git a/src/Http/Handlers/ChangelogHandler.php b/src/Http/Handlers/ChangelogHandler.php
index 68de81ed..c0a63d54 100644
--- a/src/Http/Handlers/ChangelogHandler.php
+++ b/src/Http/Handlers/ChangelogHandler.php
@@ -7,8 +7,7 @@ use Misuzu\Config;
 use Misuzu\Config\IConfig;
 use Misuzu\Pagination;
 use Misuzu\Template;
-use Misuzu\Comments\CommentsCategory;
-use Misuzu\Comments\CommentsCategoryNotFoundException;
+use Misuzu\Comments\CommentsEx;
 use Misuzu\Feeds\Feed;
 use Misuzu\Feeds\FeedItem;
 use Misuzu\Feeds\AtomFeedSerializer;
@@ -17,6 +16,8 @@ use Misuzu\Users\User;
 use Misuzu\Users\UserNotFoundException;
 
 class ChangelogHandler extends Handler {
+    private array $userInfos = [];
+
     public function index($response, $request) {
         $filterDate = (string)$request->getParam('date');
         $filterUser = (int)$request->getParam('user', FILTER_SANITIZE_NUMBER_INT);
@@ -60,13 +61,12 @@ class ChangelogHandler extends Handler {
             return 404;
 
         $changes = [];
-        $userInfos = [];
 
         foreach($changeInfos as $changeInfo) {
             $userId = $changeInfo->getUserId();
 
-            if(array_key_exists($userId, $userInfos)) {
-                $userInfo = $userInfos[$userId];
+            if(array_key_exists($userId, $this->userInfos)) {
+                $userInfo = $this->userInfos[$userId];
             } else {
                 try {
                     $userInfo = User::byId($userId);
@@ -74,7 +74,7 @@ class ChangelogHandler extends Handler {
                     $userInfo = null;
                 }
 
-                $userInfos[$userId] = $userInfo;
+                $this->userInfos[$userId] = $userInfo;
             }
 
             $changes[] = [
@@ -89,20 +89,13 @@ class ChangelogHandler extends Handler {
             'changelog_user' => $filterUser,
             'changelog_tags' => $filterTags,
             'changelog_pagination' => $pagination,
-            'comments_user' => User::getCurrent(),
-            'comments_category' => empty($filterDate) ? null : self::getCommentsCategory($changeInfos[0]->getCommentsCategoryName()),
+            'comments_info' => empty($filterDate) ? null : $this->getCommentsInfo($changeInfos[0]->getCommentsCategoryName()),
         ]));
     }
 
-    private static function getCommentsCategory(string $categoryName): CommentsCategory {
-        try {
-            $category = CommentsCategory::byName($categoryName);
-        } catch(CommentsCategoryNotFoundException $ex) {
-            $category = new CommentsCategory($categoryName);
-            $category->save();
-        }
-
-        return $category;
+    private function getCommentsInfo(string $categoryName): object {
+        $comments = new CommentsEx($this->context->getComments(), $this->userInfos);
+        return $comments->getCommentsForLayout($categoryName);
     }
 
     public function change($response, $request, string $changeId) {
@@ -121,8 +114,7 @@ class ChangelogHandler extends Handler {
         $response->setContent(Template::renderRaw('changelog.change', [
             'change_info' => $changeInfo,
             'change_user_info' => $userInfo,
-            'comments_user' => User::getCurrent(),
-            'comments_category' => self::getCommentsCategory($changeInfo->getCommentsCategoryName()),
+            'comments_info' => $this->getCommentsInfo($changeInfo->getCommentsCategoryName()),
         ]));
     }
 
diff --git a/src/Http/Handlers/HomeHandler.php b/src/Http/Handlers/HomeHandler.php
index 229039d0..c662b77c 100644
--- a/src/Http/Handlers/HomeHandler.php
+++ b/src/Http/Handlers/HomeHandler.php
@@ -1,13 +1,13 @@
 <?php
 namespace Misuzu\Http\Handlers;
 
+use RuntimeException;
 use Misuzu\Config;
 use Misuzu\Config\IConfig;
 use Misuzu\DB;
 use Misuzu\Pagination;
 use Misuzu\Template;
 use Misuzu\Comments\CommentsCategory;
-use Misuzu\Comments\CommentsCategoryNotFoundException;
 use Misuzu\Users\User;
 use Misuzu\Users\UserSession;
 use Misuzu\Users\UserNotFoundException;
@@ -107,6 +107,7 @@ final class HomeHandler extends Handler {
 
     public function home($response, $request): void {
         $news = $this->context->getNews();
+        $comments = $this->context->getComments();
         $featuredNews = [];
         $userInfos = [];
         $categoryInfos = [];
@@ -136,11 +137,8 @@ final class HomeHandler extends Handler {
             else
                 $categoryInfos[$categoryId] = $categoryInfo = $news->getCategoryByPost($postInfo);
 
-            $commentsCount = 0;
-            if($postInfo->hasCommentsCategoryId())
-                try {
-                    $commentsCount = CommentsCategory::byId($postInfo->getCommentsCategoryId())->getPostCount();
-                } catch(CommentsCategoryNotFoundException $ex) {}
+            $commentsCount = $postInfo->hasCommentsCategoryId()
+                ? $comments->countPosts($postInfo->getCommentsCategoryId(), includeReplies: true) : 0;
 
             $featuredNews[] = [
                 'post' => $postInfo,
diff --git a/src/Http/Handlers/NewsHandler.php b/src/Http/Handlers/NewsHandler.php
index 56686e28..25d129fa 100644
--- a/src/Http/Handlers/NewsHandler.php
+++ b/src/Http/Handlers/NewsHandler.php
@@ -7,7 +7,7 @@ use Misuzu\DB;
 use Misuzu\Pagination;
 use Misuzu\Template;
 use Misuzu\Comments\CommentsCategory;
-use Misuzu\Comments\CommentsCategoryNotFoundException;
+use Misuzu\Comments\CommentsEx;
 use Misuzu\Config\IConfig;
 use Misuzu\Feeds\Feed;
 use Misuzu\Feeds\FeedItem;
@@ -21,6 +21,7 @@ use Misuzu\Users\UserNotFoundException;
 final class NewsHandler extends Handler {
     private function fetchPostInfo(array $postInfos, array $categoryInfos = []): array {
         $news = $this->context->getNews();
+        $comments = $this->context->getComments();
         $posts = [];
         $userInfos = [];
 
@@ -45,11 +46,8 @@ final class NewsHandler extends Handler {
             else
                 $categoryInfos[$categoryId] = $categoryInfo = $news->getCategoryByPost($postInfo);
 
-            $commentsCount = 0;
-            if($postInfo->hasCommentsCategoryId())
-                try {
-                    $commentsCount = CommentsCategory::byId($postInfo->getCommentsCategoryId())->getPostCount();
-                } catch(CommentsCategoryNotFoundException $ex) {}
+            $commentsCount = $postInfo->hasCommentsCategoryId()
+                ? $comments->countPosts($postInfo->getCommentsCategoryId(), includeReplies: true) : 0;
 
             $posts[] = [
                 'post' => $postInfo,
@@ -116,6 +114,7 @@ final class NewsHandler extends Handler {
 
     public function viewPost($response, $request, string $postId) {
         $news = $this->context->getNews();
+        $comments = $this->context->getComments();
 
         try {
             $postInfo = $news->getPostById($postId);
@@ -128,17 +127,13 @@ final class NewsHandler extends Handler {
 
         $categoryInfo = $news->getCategoryByPost($postInfo);
 
+        $comments = $this->context->getComments();
+
         if($postInfo->hasCommentsCategoryId()) {
-            $commentsCategory = CommentsCategory::byId($postInfo->getCommentsCategoryId());
+            $commentsCategory = $comments->getCategoryById($postInfo->getCommentsCategoryId());
         } else {
-            $commentsCategoryName = $postInfo->getCommentsCategoryName();
-            try {
-                $commentsCategory = CommentsCategory::byName($commentsCategoryName);
-            } catch(CommentsCategoryNotFoundException $ex) {
-                $commentsCategory = new CommentsCategory($commentsCategoryName);
-                $commentsCategory->save();
-                $news->updatePostCommentCategory($postInfo, $commentsCategory);
-            }
+            $commentsCategory = $comments->ensureCategory($postInfo->getCommentsCategoryName());
+            $news->updatePostCommentCategory($postInfo, $commentsCategory);
         }
 
         $userInfo = null;
@@ -147,12 +142,13 @@ final class NewsHandler extends Handler {
                 $userInfo = User::byId($postInfo->getUserId());
             } catch(UserNotFoundException $ex) {}
 
+        $comments = new CommentsEx($comments);
+
         $response->setContent(Template::renderRaw('news.post', [
             'post_info' => $postInfo,
             'post_category_info' => $categoryInfo,
             'post_user_info' => $userInfo,
-            'comments_info' => $commentsCategory,
-            'comments_user' => User::getCurrent(),
+            'comments_info' => $comments->getCommentsForLayout($commentsCategory),
         ]));
     }
 
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index b50c4a8c..3efe72b2 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -3,6 +3,7 @@ namespace Misuzu;
 
 use Misuzu\Template;
 use Misuzu\Changelog\Changelog;
+use Misuzu\Comments\Comments;
 use Misuzu\Config\IConfig;
 use Misuzu\Emoticons\Emotes;
 use Misuzu\News\News;
@@ -27,6 +28,7 @@ class MisuzuContext {
     private Emotes $emotes;
     private Changelog $changelog;
     private News $news;
+    private Comments $comments;
 
     public function __construct(IDbConnection $dbConn, IConfig $config) {
         $this->dbConn = $dbConn;
@@ -35,6 +37,7 @@ class MisuzuContext {
         $this->emotes = new Emotes($this->dbConn);
         $this->changelog = new Changelog($this->dbConn);
         $this->news = new News($this->dbConn);
+        $this->comments = new Comments($this->dbConn);
     }
 
     public function getDbConn(): IDbConnection {
@@ -78,6 +81,10 @@ class MisuzuContext {
         return $this->news;
     }
 
+    public function getComments(): Comments {
+        return $this->comments;
+    }
+
     public function setUpHttp(bool $legacy = false): void {
         $this->router = new HttpFx;
         $this->router->use('/', function($response) {
diff --git a/src/News/News.php b/src/News/News.php
index f3a04da4..5c878615 100644
--- a/src/News/News.php
+++ b/src/News/News.php
@@ -8,7 +8,7 @@ use Index\Data\IDbConnection;
 use Index\Data\IDbResult;
 use Misuzu\DbStatementCache;
 use Misuzu\Pagination;
-use Misuzu\Comments\CommentsCategory;
+use Misuzu\Comments\CommentsCategoryInfo;
 use Misuzu\Users\User;
 
 class News {
@@ -465,17 +465,16 @@ class News {
 
     public function updatePostCommentCategory(
         NewsPostInfo|string $postInfo,
-        CommentsCategory|string $commentsCategory
+        CommentsCategoryInfo|string $commentsCategory
     ): void {
         if($postInfo instanceof NewsPostInfo)
             $postInfo = $postInfo->getId();
-        if($commentsCategory instanceof CommentsCategory)
-            $commentsCategory = (string)$commentsCategory->getId();
+        if($commentsCategory instanceof CommentsCategoryInfo)
+            $commentsCategory = $commentsCategory->getId();
 
-        // "post_updated = post_updated" is an Attempt at making this not bump post_updated ON UPDATE
-        $stmt = $this->cache->get('UPDATE msz_news_posts SET comment_section_id = ?, post_updated = post_updated WHERE post_id = ?');
-        $stmt->addParameter(1, $postInfo);
-        $stmt->addParameter(2, $commentsCategory);
+        $stmt = $this->cache->get('UPDATE msz_news_posts SET comment_section_id = ? WHERE post_id = ?');
+        $stmt->addParameter(1, $commentsCategory);
+        $stmt->addParameter(2, $postInfo);
         $stmt->execute();
     }
 }
diff --git a/src/TwigMisuzu.php b/src/TwigMisuzu.php
index e38b6a65..0cf2b102 100644
--- a/src/TwigMisuzu.php
+++ b/src/TwigMisuzu.php
@@ -5,10 +5,11 @@ use Twig\Extension\AbstractExtension;
 use Twig\TwigFilter;
 use Twig\TwigFunction;
 use Twig\Environment as TwigEnvironment;
-use Misuzu\Parsers\Parser;
-use Misuzu\MisuzuContext;
 use Index\ByteFormat;
 use Index\Environment;
+use Misuzu\MisuzuContext;
+use Misuzu\Comments\CommentsParser;
+use Misuzu\Parsers\Parser;
 
 final class TwigMisuzu extends AbstractExtension {
     private MisuzuContext $ctx;
@@ -22,6 +23,7 @@ final class TwigMisuzu extends AbstractExtension {
             new TwigFilter('html_colour', 'html_colour'),
             new TwigFilter('country_name', 'get_country_name'),
             new TwigFilter('parse_text', fn(string $text, int $parser): string => Parser::instance($parser)->parseText($text)),
+            new TwigFilter('parse_comment', fn(string $text): string => CommentsParser::parseForDisplay($text)),
             new TwigFilter('perms_check', 'perms_check'),
             new TwigFilter('time_diff', [$this, 'timeDiff'], ['needs_environment' => true]),
         ];
diff --git a/templates/_layout/comments.twig b/templates/_layout/comments.twig
index 89c05e14..ee01b425 100644
--- a/templates/_layout/comments.twig
+++ b/templates/_layout/comments.twig
@@ -43,9 +43,27 @@
 {% macro comments_entry(comment, indent, category, user) %}
     {% from 'macros.twig' import avatar %}
     {% from '_layout/input.twig' import input_checkbox_raw %}
-    {% set hide_details = comment.userId < 1 or comment.deleted and not user.commentPerms.can_delete_any|default(false) %}
 
-    {% if user.commentPerms.can_delete_any|default(false) or (not comment.deleted or comment.replies(user)|length > 0) %}
+    {% set replies = comment.replies %}
+    {% set poster = comment.user|default(null) %}
+    {% if comment.post is defined %}
+        {% set userVote = comment.vote.weight %}
+        {% set comment = comment.post %}
+        {% set body = comment.body %}
+        {% set likes = comment.votesPositive %}
+        {% set dislikes = comment.votesNegative %}
+        {% set isReply = comment.isReply %}
+    {% else %}
+        {% set body = comment.text %}
+        {% set userVote = comment.userVote %}
+        {% set likes = comment.likes %}
+        {% set dislikes = comment.dislikes %}
+        {% set isReply = comment.hasParent %}
+    {% endif %}
+
+    {% set hide_details = poster is null or comment.deleted and not user.commentPerms.can_delete_any|default(false) %}
+
+    {% if user.commentPerms.can_delete_any|default(false) or (not comment.deleted or replies|length > 0) %}
         <div class="comment{% if comment.deleted %} comment--deleted{% endif %}" id="comment-{{ comment.id }}">
             <div class="comment__container">
                 {% if hide_details %}
@@ -53,16 +71,16 @@
                         {{ avatar(0, indent > 1 ? 40 : 50) }}
                     </div>
                 {% else %}
-                    <a class="comment__avatar" href="{{ url('user-profile', {'user':comment.user.id}) }}">
-                        {{ avatar(comment.user.id, indent > 1 ? 40 : 50, comment.user.username) }}
+                    <a class="comment__avatar" href="{{ url('user-profile', {'user': poster.id}) }}">
+                        {{ avatar(poster.id, indent > 1 ? 40 : 50, poster.username) }}
                     </a>
                 {% endif %}
                 <div class="comment__content">
                     <div class="comment__info">
                         {% if not hide_details %}
                             <a class="comment__user comment__user--link"
-                                href="{{ url('user-profile', {'user':comment.user.id}) }}"
-                                style="--user-colour: {{ comment.user.colour}}">{{ comment.user.username }}</a>
+                                href="{{ url('user-profile', {'user': poster.id}) }}"
+                                style="--user-colour: {{ poster.colour}}">{{ poster.username }}</a>
                         {% endif %}
                         <a class="comment__link" href="#comment-{{ comment.id }}">
                             <time class="comment__date"
@@ -84,39 +102,39 @@
                         {% endif %}
                     </div>
                     <div class="comment__text">
-                        {{ hide_details ? '(deleted)' : comment.parsedText|raw }}
+                        {{ hide_details ? '(deleted)' : body|parse_comment|raw }}
                     </div>
                     <div class="comment__actions">
                         {% if not comment.deleted and user is not null %}
                             {% if user.commentPerms.can_vote|default(false) %}
-                                {% set like_vote_state = comment.userVote > 0 ? 0 : 1 %}
-                                {% set dislike_vote_state = comment.userVote < 0 ? 0 : -1 %}
+                                {% set like_vote_state = userVote > 0 ? 0 : 1 %}
+                                {% set dislike_vote_state = userVote < 0 ? 0 : -1 %}
 
-                                <a class="comment__action comment__action--link comment__action--vote comment__action--like{% if comment.userVote > 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ like_vote_state }}"
+                                <a class="comment__action comment__action--link comment__action--vote comment__action--like{% if userVote > 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ like_vote_state }}"
                                 href="{{ url('comment-vote', {'comment':comment.id,'vote':like_vote_state}) }}">
                                     Like
-                                    {% if comment.likes > 0 %}
-                                        ({{ comment.likes|number_format }})
+                                    {% if likes > 0 %}
+                                        ({{ likes|number_format }})
                                     {% endif %}
                                 </a>
-                                <a class="comment__action comment__action--link comment__action--vote comment__action--dislike{% if comment.userVote < 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ dislike_vote_state }}"
+                                <a class="comment__action comment__action--link comment__action--vote comment__action--dislike{% if userVote < 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ dislike_vote_state }}"
                                 href="{{ url('comment-vote', {'comment':comment.id,'vote':dislike_vote_state}) }}">
                                     Dislike
-                                    {% if comment.dislikes > 0 %}
-                                        ({{ comment.dislikes|number_format }})
+                                    {% if dislikes > 0 %}
+                                        ({{ dislikes|number_format }})
                                     {% endif %}
                                 </a>
                             {% endif %}
                             {% if user.commentPerms.can_comment|default(false) %}
                                 <label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.id }}">Reply</label>
                             {% endif %}
-                            {% if user.commentPerms.can_delete_any|default(false) or (comment.user.id|default(0) == user.id and user.commentPerms.can_delete|default(false)) %}
+                            {% if user.commentPerms.can_delete_any|default(false) or (poster.id|default(0) == user.id and user.commentPerms.can_delete|default(false)) %}
                                 <a class="comment__action comment__action--link comment__action--hide comment__action--delete" data-comment-id="{{ comment.id }}" href="{{ url('comment-delete', {'comment':comment.id}) }}">Delete</a>
                             {% endif %}
                             {# if user is not null %}
                                 <a class="comment__action comment__action--link comment__action--hide" href="#">Report</a>
                             {% endif #}
-                            {% if not comment.hasParent and user.commentPerms.can_pin|default(false) %}
+                            {% if not isReply and user.commentPerms.can_pin|default(false) %}
                                 <a class="comment__action comment__action--link comment__action--hide comment__action--pin" data-comment-id="{{ comment.id }}" data-comment-pinned="{{ comment.pinned ? '1' : '0' }}" href="{{ url('comment-' ~ (comment.pinned ? 'unpin' : 'pin'), {'comment':comment.id}) }}">{{ comment.pinned ? 'Unpin' : 'Pin' }}</a>
                             {% endif %}
                         {% elseif user.commentPerms.can_delete_any|default(false) %}
@@ -132,8 +150,8 @@
                     {{ input_checkbox_raw('', false, 'comment__reply-toggle', '', false, {'id':'comment-reply-toggle-' ~ comment.id}) }}
                     {{ comments_input(category, user, comment) }}
                 {% endif %}
-                {% if comment.replies|length > 0 %}
-                    {% for reply in comment.replies %}
+                {% if replies|length > 0 %}
+                    {% for reply in replies %}
                         {{ comments_entry(reply, indent + 1, category, user) }}
                     {% endfor %}
                 {% endif %}
@@ -143,6 +161,14 @@
 {% endmacro %}
 
 {% macro comments_section(category, user) %}
+    {% if category.category is defined %}
+        {% set user = category.user %}
+        {% set posts = category.posts %}
+        {% set category = category.category %}
+    {% else %}
+        {% set posts = category.posts %}
+    {% endif %}
+
     <div class="comments" id="comments">
         <div class="comments__input">
             {% if user|default(null) is null %}
@@ -180,9 +206,9 @@
         </noscript>#}
 
         <div class="comments__listing">
-            {% if category.posts|length > 0 %}
+            {% if posts|length > 0 %}
                 {% from _self import comments_entry %}
-                {% for comment in category.posts(user) %}
+                {% for comment in posts %}
                     {{ comments_entry(comment, 1, category, user) }}
                 {% endfor %}
             {% else %}
diff --git a/templates/changelog/change.twig b/templates/changelog/change.twig
index 16eeb202..dcc26b24 100644
--- a/templates/changelog/change.twig
+++ b/templates/changelog/change.twig
@@ -69,6 +69,6 @@
 
     <div class="container">
         {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments for ' ~ change_info.date) }}
-        {{ comments_section(comments_category, comments_user) }}
+        {{ comments_section(comments_info) }}
     </div>
 {% endblock %}
diff --git a/templates/changelog/index.twig b/templates/changelog/index.twig
index 9d59ba74..3cbdf134 100644
--- a/templates/changelog/index.twig
+++ b/templates/changelog/index.twig
@@ -58,7 +58,7 @@
     {% if is_date %}
         <div class="container">
             {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }}
-            {{ comments_section(comments_category, comments_user) }}
+            {{ comments_section(comments_info) }}
         </div>
     {% endif %}
 {% endblock %}
diff --git a/templates/news/post.twig b/templates/news/post.twig
index 7bfe87dc..3c905737 100644
--- a/templates/news/post.twig
+++ b/templates/news/post.twig
@@ -13,7 +13,7 @@
     {% if comments_info is defined %}
         <div class="container">
             {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }}
-            {{ comments_section(comments_info, comments_user) }}
+            {{ comments_section(comments_info) }}
         </div>
     {% endif %}
 {% endblock %}