diff --git a/assets/css/misuzu/changelog/_changelog.css b/assets/css/misuzu/changelog/_changelog.css
new file mode 100644
index 00000000..1cd8f095
--- /dev/null
+++ b/assets/css/misuzu/changelog/_changelog.css
@@ -0,0 +1,6 @@
+.changelog__action--add     { --action-colour: #159635 !important; }
+.changelog__action--remove  { --action-colour: #e33743 !important; }
+.changelog__action--update  { --action-colour: #297b8a !important; }
+.changelog__action--fix     { --action-colour: #2d5e96 !important; }
+.changelog__action--import  { --action-colour: #2b9678 !important; }
+.changelog__action--revert  { --action-colour: #e38245 !important; }
diff --git a/assets/css/misuzu/changelog/entry.css b/assets/css/misuzu/changelog/entry.css
index 9d53bcef..21a27bfc 100644
--- a/assets/css/misuzu/changelog/entry.css
+++ b/assets/css/misuzu/changelog/entry.css
@@ -30,12 +30,6 @@
 .changelog__entry__action__text {
     width: 100%;
 }
-.changelog__action--add     { --action-colour: #159635; }
-.changelog__action--remove  { --action-colour: #e33743; }
-.changelog__action--update  { --action-colour: #297b8a; }
-.changelog__action--fix     { --action-colour: #2d5e96; }
-.changelog__action--import  { --action-colour: #2b9678; }
-.changelog__action--revert  { --action-colour: #e38245; }
 
 .changelog__entry__datetime {
     min-width: 100px;
diff --git a/assets/css/misuzu/changelog/log.css b/assets/css/misuzu/changelog/log.css
index 15bc14fe..77da047a 100644
--- a/assets/css/misuzu/changelog/log.css
+++ b/assets/css/misuzu/changelog/log.css
@@ -2,7 +2,7 @@
     --action-colour: var(--accent-colour);
 
     border: 1px solid var(--action-colour);
-    background-color: var(--action-colour);
+    background-color: var(--background-colour);
     display: flex;
     align-items: stretch;
     flex: 1 0 auto;
diff --git a/misuzu.php b/misuzu.php
index a55fdd27..22d24ddd 100644
--- a/misuzu.php
+++ b/misuzu.php
@@ -64,7 +64,6 @@ require_once 'utility.php';
 require_once 'src/perms.php';
 require_once 'src/audit_log.php';
 require_once 'src/changelog.php';
-require_once 'src/comments.php';
 require_once 'src/manage.php';
 require_once 'src/url.php';
 require_once 'src/Forum/perms.php';
diff --git a/public/auth/login.php b/public/auth/login.php
index c6fc8eae..9033e49d 100644
--- a/public/auth/login.php
+++ b/public/auth/login.php
@@ -3,6 +3,7 @@ namespace Misuzu;
 
 use Misuzu\Net\IPAddress;
 use Misuzu\Users\User;
+use Misuzu\Users\UserNotFoundException;
 
 require_once '../../misuzu.php';
 
@@ -44,7 +45,6 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
         break;
     }
 
-    $userData = User::findForLogin($_POST['login']['username']);
     $attemptsRemainingError = sprintf(
         "%d attempt%s remaining",
         $remainingAttempts - 1,
@@ -52,7 +52,9 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
     );
     $loginFailedError = "Invalid username or password, {$attemptsRemainingError}.";
 
-    if(empty($userData)) {
+    try {
+        $userData = User::findForLogin($_POST['login']['username']);
+    } catch(UserNotFoundException $ex) {
         user_login_attempt_record(false, null, $ipAddress, $userAgent);
         $notices[] = $loginFailedError;
         break;
@@ -64,7 +66,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
     }
 
     if($userData->isDeleted() || !$userData->checkPassword($_POST['login']['password'])) {
-        user_login_attempt_record(false, $userData->user_id, $ipAddress, $userAgent);
+        user_login_attempt_record(false, $userData->getId(), $ipAddress, $userAgent);
         $notices[] = $loginFailedError;
         break;
     }
@@ -73,31 +75,31 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
         $userData->setPassword($_POST['login']['password']);
     }
 
-    if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userData['user_id'], $loginPermVal)) {
+    if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userData->getId(), $loginPermVal)) {
         $notices[] = "Login succeeded, but you're not allowed to browse the site right now.";
-        user_login_attempt_record(true, $userData->user_id, $ipAddress, $userAgent);
+        user_login_attempt_record(true, $userData->getId(), $ipAddress, $userAgent);
         break;
     }
 
     if($userData->hasTOTP()) {
         url_redirect('auth-two-factor', [
-            'token' => user_auth_tfa_token_create($userData->user_id),
+            'token' => user_auth_tfa_token_create($userData->getId()),
         ]);
         return;
     }
 
-    user_login_attempt_record(true, $userData->user_id, $ipAddress, $userAgent);
-    $sessionKey = user_session_create($userData->user_id, $ipAddress, $userAgent);
+    user_login_attempt_record(true, $userData->getId(), $ipAddress, $userAgent);
+    $sessionKey = user_session_create($userData->getId(), $ipAddress, $userAgent);
 
     if(empty($sessionKey)) {
         $notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!";
         break;
     }
 
-    user_session_start($userData->user_id, $sessionKey);
+    user_session_start($userData->getId(), $sessionKey);
 
     $cookieLife = strtotime(user_session_current('session_expires'));
-    $cookieValue = Base64::encode(user_session_cookie_pack($userData->user_id, $sessionKey), true);
+    $cookieValue = Base64::encode(user_session_cookie_pack($userData->getId(), $sessionKey), true);
     setcookie('msz_auth', $cookieValue, $cookieLife, '/', '.' . $_SERVER['HTTP_HOST'], !empty($_SERVER['HTTPS']), true);
 
     if(!is_local_url($loginRedirect)) {
diff --git a/public/auth/register.php b/public/auth/register.php
index 04d15b77..c5dfc5b4 100644
--- a/public/auth/register.php
+++ b/public/auth/register.php
@@ -82,8 +82,8 @@ while(!$restricted && !empty($register)) {
         break;
     }
 
-    user_role_add($createUser->user_id, MSZ_ROLE_MAIN);
-    url_redirect('auth-login-welcome', ['username' => $createUser->username]);
+    user_role_add($createUser->getId(), MSZ_ROLE_MAIN);
+    url_redirect('auth-login-welcome', ['username' => $createUser->getUsername()]);
     return;
 }
 
diff --git a/public/changelog.php b/public/changelog.php
index 4f20fd26..ba3f45bd 100644
--- a/public/changelog.php
+++ b/public/changelog.php
@@ -1,14 +1,17 @@
 <?php
 namespace Misuzu;
 
+use Misuzu\Comments\CommentsCategory;
+use Misuzu\Comments\CommentsCategoryNotFoundException;
+use Misuzu\Users\User;
+use Misuzu\Users\UserNotFoundException;
+
 require_once '../misuzu.php';
 
-$changelogChange = !empty($_GET['c']) && is_string($_GET['c']) ? (int)$_GET['c'] : 0;
-$changelogDate = !empty($_GET['d']) && is_string($_GET['d']) ? (string)$_GET['d'] : '';
-$changelogUser = !empty($_GET['u']) && is_string($_GET['u']) ? (int)$_GET['u'] : 0;
-$changelogTags = !empty($_GET['t']) && is_string($_GET['t']) ? (string)$_GET['t'] : '';
-
-Template::set('comments_perms', $commentPerms = comments_get_perms(user_session_current('user_id', 0)));
+$changelogChange = !empty($_GET['c']) && is_string($_GET['c']) ?    (int)$_GET['c'] :  0;
+$changelogDate   = !empty($_GET['d']) && is_string($_GET['d']) ? (string)$_GET['d'] : '';
+$changelogUser   = !empty($_GET['u']) && is_string($_GET['u']) ?    (int)$_GET['u'] :  0;
+$changelogTags   = !empty($_GET['t']) && is_string($_GET['t']) ? (string)$_GET['t'] : '';
 
 if($changelogChange > 0) {
     $change = changelog_change_get($changelogChange);
@@ -18,14 +21,25 @@ if($changelogChange > 0) {
         return;
     }
 
+    $commentsCategoryName = "changelog-date-{$change['change_date']}";
+    try {
+        $commentsCategory = CommentsCategory::byName($commentsCategoryName);
+    } catch(CommentsCategoryNotFoundException $ex) {
+        $commentsCategory = new CommentsCategory($commentsCategoryName);
+        $commentsCategory->save();
+    }
+
+    try {
+        $commentsUser = User::byId(user_session_current('user_id', 0));
+    } catch(UserNotFoundException $ex) {
+        $commentsUser = null;
+    }
+
     Template::render('changelog.change', [
         'change' => $change,
         'tags' => changelog_change_tags_get($change['change_id']),
-        'comments_category' => $commentsCategory = comments_category_info(
-            "changelog-date-{$change['change_date']}",
-            true
-        ),
-        'comments' => comments_category_get($commentsCategory['category_id'], user_session_current('user_id', 0)),
+        'comments_category' => $commentsCategory,
+        'comments_user' => $commentsUser,
     ]);
     return;
 }
@@ -52,9 +66,23 @@ if(!$changes) {
 }
 
 if(!empty($changelogDate) && count($changes) > 0) {
+    $commentsCategoryName = "changelog-date-{$changelogDate}";
+    try {
+        $commentsCategory = CommentsCategory::byName($commentsCategoryName);
+    } catch(CommentsCategoryNotFoundException $ex) {
+        $commentsCategory = new CommentsCategory($commentsCategoryName);
+        $commentsCategory->save();
+    }
+
+    try {
+        $commentsUser = User::byId(user_session_current('user_id', 0));
+    } catch(UserNotFoundException $ex) {
+        $commentsUser = null;
+    }
+
     Template::set([
-        'comments_category' => $commentsCategory = comments_category_info("changelog-date-{$changelogDate}", true),
-        'comments' => comments_category_get($commentsCategory['category_id'], user_session_current('user_id', 0)),
+        'comments_category' => $commentsCategory,
+        'comments_user' => $commentsUser,
     ]);
 }
 
diff --git a/public/comments.php b/public/comments.php
index 84353d60..6cafd119 100644
--- a/public/comments.php
+++ b/public/comments.php
@@ -1,6 +1,15 @@
 <?php
 namespace Misuzu;
 
+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;
+
 require_once '../misuzu.php';
 
 // basing whether or not this is an xhr request on whether a referrer header is present
@@ -20,28 +29,36 @@ if(!CSRF::validateRequest()) {
     return;
 }
 
-if(!user_session_active()) {
+try {
+    $currentUserInfo = User::byId(user_session_current('user_id', 0));
+} catch(UserNotFoundException $ex) {
     echo render_info_or_json($isXHR, 'You must be logged in to manage comments.', 401);
     return;
 }
 
-$currentUserId = user_session_current('user_id', 0);
-
-if(user_warning_check_expiration($currentUserId, MSZ_WARN_BAN) > 0) {
+if(user_warning_check_expiration($currentUserInfo->getId(), MSZ_WARN_BAN) > 0) {
     echo render_info_or_json($isXHR, 'You have been banned, check your profile for more information.', 403);
     return;
 }
-if(user_warning_check_expiration($currentUserId, MSZ_WARN_SILENCE) > 0) {
+if(user_warning_check_expiration($currentUserInfo->getId(), MSZ_WARN_SILENCE) > 0) {
     echo render_info_or_json($isXHR, 'You have been silenced, check your profile for more information.', 403);
     return;
 }
 
 header(CSRF::header());
-$commentPerms = comments_get_perms($currentUserId);
+$commentPerms = $currentUserInfo->commentPerms();
 
-$commentId = !empty($_GET['c']) && is_string($_GET['c']) ? (int)$_GET['c'] : 0;
-$commentMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
-$commentVote = !empty($_GET['v']) && is_string($_GET['v']) ? (int)$_GET['v'] : MSZ_COMMENTS_VOTE_INDIFFERENT;
+$commentId   = (int)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
+$commentMode =      filter_input(INPUT_GET, 'm');
+$commentVote = (int)filter_input(INPUT_GET, 'v', FILTER_SANITIZE_NUMBER_INT);
+
+if($commentId > 0)
+    try {
+        $commentInfo2 = CommentsPost::byId($commentId);
+    } catch(CommentsPostNotFoundException $ex) {
+        echo render_info_or_json($isXHR, 'Post not found.', 404);
+        return;
+    }
 
 switch($commentMode) {
     case 'pin':
@@ -51,38 +68,37 @@ switch($commentMode) {
             break;
         }
 
-        $commentInfo = comments_post_get($commentId, false);
-
-        if(!$commentInfo || $commentInfo['comment_deleted'] !== null) {
+        if($commentInfo2->isDeleted()) {
             echo render_info_or_json($isXHR, "This comment doesn't exist!", 400);
             break;
         }
 
-        if($commentInfo['comment_reply_to'] !== null) {
+        if($commentInfo2->hasParent()) {
             echo render_info_or_json($isXHR, "You can't pin replies!", 400);
             break;
         }
 
         $isPinning = $commentMode === 'pin';
 
-        if($isPinning && !empty($commentInfo['comment_pinned'])) {
+        if($isPinning && $commentInfo2->isPinned()) {
             echo render_info_or_json($isXHR, 'This comment is already pinned.', 400);
             break;
-        } elseif(!$isPinning && empty($commentInfo['comment_pinned'])) {
+        } elseif(!$isPinning && !$commentInfo2->isPinned()) {
             echo render_info_or_json($isXHR, "This comment isn't pinned yet.", 400);
             break;
         }
 
-        $commentPinned = comments_pin_status($commentInfo['comment_id'], $isPinning);
+        $commentInfo2->setPinned($isPinning);
+        $commentInfo2->save();
 
         if(!$isXHR) {
-            redirect($redirect . '#comment-' . $commentInfo['comment_id']);
+            redirect($redirect . '#comment-' . $commentInfo2->getId());
             break;
         }
 
         echo json_encode([
-            'comment_id' => $commentInfo['comment_id'],
-            'comment_pinned' => $commentPinned,
+            'comment_id'     => $commentInfo2->getId(),
+            'comment_pinned' => ($time = $commentInfo2->getPinnedTime()) < 0 ? null : date('Y-m-d H:i:s', $time),
         ]);
         break;
 
@@ -92,30 +108,24 @@ switch($commentMode) {
             break;
         }
 
-        if(!comments_vote_type_valid($commentVote)) {
-            echo render_info_or_json($isXHR, 'Invalid vote action.', 400);
-            break;
-        }
-
-        $commentInfo = comments_post_get($commentId, false);
-
-        if(!$commentInfo || $commentInfo['comment_deleted'] !== null) {
+        if($commentInfo2->isDeleted()) {
             echo render_info_or_json($isXHR, "This comment doesn't exist!", 400);
             break;
         }
 
-        $voteResult = comments_vote_add(
-            $commentInfo['comment_id'],
-            user_session_current('user_id', 0),
-            $commentVote
-        );
+        if($commentVote > 0)
+            $commentInfo2->addPositiveVote($currentUserInfo);
+        elseif($commentVote < 0)
+            $commentInfo2->addNegativeVote($currentUserInfo);
+        else
+            $commentInfo2->removeVote($currentUserInfo);
 
         if(!$isXHR) {
-            redirect($redirect . '#comment-' . $commentInfo['comment_id']);
+            redirect($redirect . '#comment-' . $commentInfo2->getId());
             break;
         }
 
-        echo json_encode(comments_votes_get($commentInfo['comment_id']));
+        echo json_encode($commentInfo2->votes());
         break;
 
     case 'delete':
@@ -124,17 +134,7 @@ switch($commentMode) {
             break;
         }
 
-        $commentInfo = comments_post_get($commentId, false);
-
-        if(!$commentInfo) {
-            echo render_info_or_json($isXHR, "This comment doesn't exist.", 400);
-            break;
-        }
-
-        $isOwnComment = (int)$commentInfo['user_id'] === $currentUserId;
-        $isModAction = $commentPerms['can_delete_any'] && !$isOwnComment;
-
-        if($commentInfo['comment_deleted'] !== null) {
+        if($commentInfo2->isDeleted()) {
             echo render_info_or_json(
                 $isXHR,
                 $commentPerms['can_delete_any'] ? 'This comment is already marked for deletion.' : "This comment doesn't exist.",
@@ -143,24 +143,25 @@ switch($commentMode) {
             break;
         }
 
+        $isOwnComment = $commentInfo2->getUserId() === $currentUserInfo->getId();
+        $isModAction  = $commentPerms['can_delete_any'] && !$isOwnComment;
+
         if(!$isModAction && !$isOwnComment) {
             echo render_info_or_json($isXHR, "You're not allowed to delete comments made by others.", 403);
             break;
         }
 
-        if(!comments_post_delete($commentInfo['comment_id'])) {
-            echo render_info_or_json($isXHR, 'Failed to delete comment.', 500);
-            break;
-        }
+        $commentInfo2->setDeleted(true);
+        $commentInfo2->save();
 
         if($isModAction) {
-            audit_log(MSZ_AUDIT_COMMENT_ENTRY_DELETE_MOD, $currentUserId, [
-                $commentInfo['comment_id'],
-                (int)($commentInfo['user_id'] ?? 0),
-                $commentInfo['username'] ?? '(Deleted User)',
+            audit_log(MSZ_AUDIT_COMMENT_ENTRY_DELETE_MOD, $currentUserInfo->getId(), [
+                $commentInfo2->getId(),
+                $commentUserId = $commentInfo2->getUserId(),
+                ($commentUserId < 1 ? '(Deleted User)' : $commentInfo2->getUser()->getUsername()),
             ]);
         } else {
-            audit_log(MSZ_AUDIT_COMMENT_ENTRY_DELETE, $currentUserId, [$commentInfo['comment_id']]);
+            audit_log(MSZ_AUDIT_COMMENT_ENTRY_DELETE, $currentUserInfo->getId(), [$commentInfo2->getId()]);
         }
 
         if($redirect) {
@@ -169,7 +170,7 @@ switch($commentMode) {
         }
 
         echo json_encode([
-            'id' => $commentInfo['comment_id'],
+            'id' => $commentInfo2->getId(),
         ]);
         break;
 
@@ -179,36 +180,27 @@ switch($commentMode) {
             break;
         }
 
-        $commentInfo = comments_post_get($commentId, false);
-
-        if(!$commentInfo) {
-            echo render_info_or_json($isXHR, "This comment doesn't exist.", 400);
-            break;
-        }
-
-        if($commentInfo['comment_deleted'] === null) {
+        if(!$commentInfo2->isDeleted()) {
             echo render_info_or_json($isXHR, "This comment isn't in a deleted state.", 400);
             break;
         }
 
-        if(!comments_post_delete($commentInfo['comment_id'], false)) {
-            echo render_info_or_json($isXHR, 'Failed to restore comment.', 500);
-            break;
-        }
+        $commentInfo2->setDeleted(false);
+        $commentInfo2->save();
 
-        audit_log(MSZ_AUDIT_COMMENT_ENTRY_RESTORE, $currentUserId, [
-            $commentInfo['comment_id'],
-            (int)($commentInfo['user_id'] ?? 0),
-            $commentInfo['username'] ?? '(Deleted User)',
+        audit_log(MSZ_AUDIT_COMMENT_ENTRY_RESTORE, $currentUserInfo->getId(), [
+            $commentInfo2->getId(),
+            $commentUserId = $commentInfo2->getUserId(),
+            ($commentUserId < 1 ? '(Deleted User)' : $commentInfo2->getUser()->getUsername()),
         ]);
 
         if($redirect) {
-            redirect($redirect . '#comment-' . $commentInfo['comment_id']);
+            redirect($redirect . '#comment-' . $commentInfo2->getId());
             break;
         }
 
         echo json_encode([
-            'id' => $commentInfo['comment_id'],
+            'id' => $commentInfo2->getId(),
         ]);
         break;
 
@@ -223,26 +215,30 @@ switch($commentMode) {
             break;
         }
 
-        $categoryId = !empty($_POST['comment']['category']) && is_string($_POST['comment']['category']) ? (int)$_POST['comment']['category'] : 0;
-        $category = comments_category_info($categoryId);
-
-        if(!$category) {
+        try {
+            $categoryInfo = CommentsCategory::byId(
+                isset($_POST['comment']['category']) && is_string($_POST['comment']['category'])
+                    ? (int)$_POST['comment']['category']
+                    : 0
+            );
+        } catch(CommentsCategoryNotFoundException $ex) {
             echo render_info_or_json($isXHR, 'This comment category doesn\'t exist.', 404);
             break;
         }
 
-        if(!is_null($category['category_locked']) && !$commentPerms['can_lock']) {
+        if($categoryInfo->isLocked() && !$commentPerms['can_lock']) {
             echo render_info_or_json($isXHR, 'This comment category has been locked.', 403);
             break;
         }
 
-        $commentText = !empty($_POST['comment']['text']) && is_string($_POST['comment']['text']) ? $_POST['comment']['text'] : '';
-        $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 = !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) {
-            comments_category_lock($categoryId, is_null($category['category_locked']));
+            $categoryInfo->setLocked(!$categoryInfo->isLocked());
+            $categoryInfo->save();
         }
 
         if(strlen($commentText) > 0) {
@@ -261,30 +257,53 @@ switch($commentMode) {
             break;
         }
 
-        if($commentReply > 0 && !comments_post_exists($commentReply)) {
-            echo render_info_or_json($isXHR, 'The comment you tried to reply to does not exist.', 404);
-            break;
+        if($commentReply > 0) {
+            try {
+                $parentCommentInfo = CommentsPost::byId($commentReply);
+            } catch(CommentsPostNotFoundException $ex) {
+                unset($parentCommentInfo);
+            }
+
+            if(!isset($parentCommentInfo) || $parentCommentInfo->isDeleted()) {
+                echo render_info_or_json($isXHR, 'The comment you tried to reply to does not exist.', 404);
+                break;
+            }
         }
 
-        $commentId = comments_post_create(
-            user_session_current('user_id', 0),
-            $categoryId,
-            $commentText,
-            $commentPin,
-            $commentReply
-        );
+        $commentInfo2 = (new CommentsPost)
+            ->setUser($currentUserInfo)
+            ->setCategory($categoryInfo)
+            ->setParsedText($commentText)
+            ->setPinned($commentPin);
 
-        if($commentId < 1) {
+        if(isset($parentCommentInfo))
+            $commentInfo2->setParent($parentCommentInfo);
+
+        try {
+            $commentInfo2->save();
+        } catch(CommentsPostSaveFailedException $ex) {
             echo render_info_or_json($isXHR, 'Something went horribly wrong.', 500);
             break;
         }
 
         if($redirect) {
-            redirect($redirect . '#comment-' . $commentId);
+            redirect($redirect . '#comment-' . $commentInfo2->getId());
             break;
         }
 
-        echo json_encode(comments_post_get($commentId));
+        echo json_encode([
+            'comment_id'       => $commentInfo2->getId(),
+            'category_id'      => $commentInfo2->getCategoryId(),
+            'comment_text'     => $commentInfo2->getText(),
+            'comment_created'  => ($time = $commentInfo2->getCreatedTime()) < 0 ? null : date('Y-m-d H:i:s', $time),
+            'comment_edited'   => ($time = $commentInfo2->getEditedTime())  < 0 ? null : date('Y-m-d H:i:s', $time),
+            'comment_deleted'  => ($time = $commentInfo2->getDeletedTime()) < 0 ? null : date('Y-m-d H:i:s', $time),
+            'comment_pinned'   => ($time = $commentInfo2->getPinnedTime())  < 0 ? null : date('Y-m-d H:i:s', $time),
+            'comment_reply_to' => ($parent = $commentInfo2->getParentId())  < 1 ? null : $parent,
+            'user_id'          => ($commentInfo2->getUserId() < 1 ? null       : $commentInfo2->getUser()->getId()),
+            'username'         => ($commentInfo2->getUserId() < 1 ? null       : $commentInfo2->getUser()->getUsername()),
+            'user_colour'      => ($commentInfo2->getUserId() < 1 ? 0x40000000 : $commentInfo2->getUser()->getColour()->getRaw()),
+        ]);
         break;
 
     default:
diff --git a/public/index.php b/public/index.php
index b0f0c35a..e5272b66 100644
--- a/public/index.php
+++ b/public/index.php
@@ -72,9 +72,13 @@ $responseStatus = $response->getStatusCode();
 
 header('HTTP/' . $response->getProtocolVersion() . ' ' . $responseStatus . ' ' . $response->getReasonPhrase());
 
-foreach($response->getHeaders() as $headerName => $headerSet)
-    foreach($headerSet as $headerLine)
-        header("{$headerName}: {$headerLine}");
+foreach($response->getHeaders() as $name => $lines) {
+    $firstLine = true;
+    foreach($lines as $line) {
+        header("{$name}: {$line}", $firstLine);
+        $firstLine = false;
+    }
+}
 
 $responseBody = $response->getBody();
 
diff --git a/public/profile.php b/public/profile.php
index e77c844d..82b72868 100644
--- a/public/profile.php
+++ b/public/profile.php
@@ -3,6 +3,7 @@ namespace Misuzu;
 
 use Misuzu\Parsers\Parser;
 use Misuzu\Users\User;
+use Misuzu\Users\UserNotFoundException;
 
 require_once '../misuzu.php';
 
@@ -10,9 +11,9 @@ $userId = !empty($_GET['u']) && is_string($_GET['u']) ? $_GET['u'] : 0;
 $profileMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
 $isEditing = !empty($_GET['edit']) && is_string($_GET['edit']) ? (bool)$_GET['edit'] : !empty($_POST) && is_array($_POST);
 
-$profileUser = User::findForProfile($userId);
-
-if(empty($profileUser)) {
+try {
+    $profileUser = User::findForProfile($userId);
+} catch(UserNotFoundException $ex) {
     http_response_code(404);
     Template::render('profile.index');
     return;
@@ -22,9 +23,9 @@ $notices = [];
 
 $currentUserId = user_session_current('user_id', 0);
 $viewingAsGuest = $currentUserId === 0;
-$viewingOwnProfile = $currentUserId === $profileUser->user_id;
+$viewingOwnProfile = $currentUserId === $profileUser->getId();
 
-$isBanned = user_warning_check_restriction($profileUser->user_id);
+$isBanned = user_warning_check_restriction($profileUser->getId());
 $userPerms = perms_get_user($currentUserId)[MSZ_PERMS_USER];
 $canManageWarnings = perms_check($userPerms, MSZ_PERM_USER_MANAGE_WARNINGS);
 $canEdit = !$isBanned
@@ -34,7 +35,7 @@ $canEdit = !$isBanned
         || user_check_super($currentUserId)
         || (
             perms_check($userPerms, MSZ_PERM_USER_MANAGE_USERS)
-            && user_check_authority($currentUserId, $profileUser->user_id)
+            && user_check_authority($currentUserId, $profileUser->getId())
         )
     );
 
@@ -87,7 +88,7 @@ if($isEditing) {
                     $notices[] = MSZ_TMP_USER_ERROR_STRINGS['about']['not-allowed'];
                 } else {
                     $setAboutError = user_set_about_page(
-                        $profileUser->user_id,
+                        $profileUser->getId(),
                         $_POST['about']['text'] ?? '',
                         (int)($_POST['about']['parser'] ?? Parser::PLAIN)
                     );
@@ -106,7 +107,7 @@ if($isEditing) {
                     $notices[] = MSZ_TMP_USER_ERROR_STRINGS['signature']['not-allowed'];
                 } else {
                     $setSignatureError = user_set_signature(
-                        $profileUser->user_id,
+                        $profileUser->getId(),
                         $_POST['signature']['text'] ?? '',
                         (int)($_POST['signature']['parser'] ?? Parser::PLAIN)
                     );
@@ -125,7 +126,7 @@ if($isEditing) {
                     $notices[] = "You aren't allow to change your birthdate.";
                 } else {
                     $setBirthdate = user_set_birthdate(
-                        $profileUser->user_id,
+                        $profileUser->getId(),
                         (int)($_POST['birthdate']['day'] ?? 0),
                         (int)($_POST['birthdate']['month'] ?? 0),
                         (int)($_POST['birthdate']['year'] ?? 0)
@@ -154,7 +155,7 @@ if($isEditing) {
 
             if(!empty($_FILES['avatar'])) {
                 if(!empty($_POST['avatar']['delete'])) {
-                    user_avatar_delete($profileUser->user_id);
+                    user_avatar_delete($profileUser->getId());
                 } else {
                     if(!$perms['edit_avatar']) {
                         $notices[] = MSZ_TMP_USER_ERROR_STRINGS['avatar']['not-allowed'];
@@ -172,7 +173,7 @@ if($isEditing) {
                             );
                         } else {
                             $setAvatar = user_avatar_set_from_path(
-                                $profileUser->user_id,
+                                $profileUser->getId(),
                                 $_FILES['avatar']['tmp_name']['file'],
                                 $avatarProps
                             );
@@ -194,8 +195,8 @@ if($isEditing) {
 
             if(!empty($_FILES['background'])) {
                 if((int)($_POST['background']['attach'] ?? -1) === 0) {
-                    user_background_delete($profileUser->user_id);
-                    user_background_set_settings($profileUser->user_id, MSZ_USER_BACKGROUND_ATTACHMENT_NONE);
+                    user_background_delete($profileUser->getId());
+                    user_background_set_settings($profileUser->getId(), MSZ_USER_BACKGROUND_ATTACHMENT_NONE);
                 } else {
                     if(!$perms['edit_background']) {
                         $notices[] = MSZ_TMP_USER_ERROR_STRINGS['background']['not-allowed'];
@@ -213,7 +214,7 @@ if($isEditing) {
                                 );
                             } else {
                                 $setBackground = user_background_set_from_path(
-                                    $profileUser->user_id,
+                                    $profileUser->getId(),
                                     $_FILES['background']['tmp_name']['file'],
                                     $backgroundProps
                                 );
@@ -243,7 +244,7 @@ if($isEditing) {
                             $backgroundSettings |= MSZ_USER_BACKGROUND_ATTRIBUTE_SLIDE;
                         }
 
-                        user_background_set_settings($profileUser->user_id, $backgroundSettings);
+                        user_background_set_settings($profileUser->getId(), $backgroundSettings);
                     }
                 }
             }
@@ -294,23 +295,23 @@ $profileStats = DB::prepare(sprintf('
     ) AS `following_count`
     FROM `msz_users` AS u
     WHERE `user_id` = :user_id
-', MSZ_USER_RELATION_FOLLOW))->bind('user_id', $profileUser->user_id)->fetch();
+', MSZ_USER_RELATION_FOLLOW))->bind('user_id', $profileUser->getId())->fetch();
 
 $relationInfo = user_session_active()
-    ? user_relation_info($currentUserId, $profileUser->user_id)
+    ? user_relation_info($currentUserId, $profileUser->getId())
     : [];
 
-$backgroundPath = sprintf('%s/backgrounds/original/%d.msz', MSZ_STORAGE, $profileUser->user_id);
+$backgroundPath = sprintf('%s/backgrounds/original/%d.msz', MSZ_STORAGE, $profileUser->getId());
 
 if(is_file($backgroundPath)) {
     $backgroundInfo = getimagesize($backgroundPath);
 
     if($backgroundInfo) {
         Template::set('site_background', [
-            'url' => url('user-background', ['user' => $profileUser->user_id]),
+            'url' => url('user-background', ['user' => $profileUser->getId()]),
             'width' => $backgroundInfo[0],
             'height' => $backgroundInfo[1],
-            'settings' => $profileUser->user_background_settings,
+            'settings' => $profileUser->getBackgroundSettings(),
         ]);
     }
 }
@@ -322,7 +323,7 @@ switch($profileMode) {
 
     case 'following':
         $template = 'profile.relations';
-        $followingCount = user_relation_count_from($profileUser->user_id, MSZ_USER_RELATION_FOLLOW);
+        $followingCount = user_relation_count_from($profileUser->getId(), MSZ_USER_RELATION_FOLLOW);
         $followingPagination = new Pagination($followingCount, MSZ_USER_RELATION_FOLLOW_PER_PAGE);
 
         if(!$followingPagination->hasValidOffset()) {
@@ -331,14 +332,14 @@ switch($profileMode) {
         }
 
         $following = user_relation_users_from(
-            $profileUser->user_id, MSZ_USER_RELATION_FOLLOW,
+            $profileUser->getId(), MSZ_USER_RELATION_FOLLOW,
             $followingPagination->getRange(), $followingPagination->getOffset(),
             $currentUserId
         );
 
         Template::set([
-            'title' => $profileUser->username . ' / following',
-            'canonical_url' => url('user-profile-following', ['user' => $profileUser->user_id]),
+            'title' => $profileUser->getUsername() . ' / following',
+            'canonical_url' => url('user-profile-following', ['user' => $profileUser->getId()]),
             'profile_users' => $following,
             'profile_relation_pagination' => $followingPagination,
         ]);
@@ -346,7 +347,7 @@ switch($profileMode) {
 
     case 'followers':
         $template = 'profile.relations';
-        $followerCount = user_relation_count_to($profileUser->user_id, MSZ_USER_RELATION_FOLLOW);
+        $followerCount = user_relation_count_to($profileUser->getId(), MSZ_USER_RELATION_FOLLOW);
         $followerPagination = new Pagination($followerCount, MSZ_USER_RELATION_FOLLOW_PER_PAGE);
 
         if(!$followerPagination->hasValidOffset()) {
@@ -355,14 +356,14 @@ switch($profileMode) {
         }
 
         $followers = user_relation_users_to(
-            $profileUser->user_id, MSZ_USER_RELATION_FOLLOW,
+            $profileUser->getId(), MSZ_USER_RELATION_FOLLOW,
             $followerPagination->getRange(), $followerPagination->getOffset(),
             $currentUserId
         );
 
         Template::set([
-            'title' => $profileUser->username . ' / followers',
-            'canonical_url' => url('user-profile-followers', ['user' => $profileUser->user_id]),
+            'title' => $profileUser->getUsername() . ' / followers',
+            'canonical_url' => url('user-profile-followers', ['user' => $profileUser->getId()]),
             'profile_users' => $followers,
             'profile_relation_pagination' => $followerPagination,
         ]);
@@ -370,7 +371,7 @@ switch($profileMode) {
 
     case 'forum-topics':
         $template = 'profile.topics';
-        $topicsCount = forum_topic_count_user($profileUser->user_id, $currentUserId);
+        $topicsCount = forum_topic_count_user($profileUser->getId(), $currentUserId);
         $topicsPagination = new Pagination($topicsCount, 20);
 
         if(!$topicsPagination->hasValidOffset()) {
@@ -379,13 +380,13 @@ switch($profileMode) {
         }
 
         $topics = forum_topic_listing_user(
-            $profileUser->user_id, $currentUserId,
+            $profileUser->getId(), $currentUserId,
             $topicsPagination->getOffset(), $topicsPagination->getRange()
         );
 
         Template::set([
-            'title' => $profileUser->username . ' / topics',
-            'canonical_url' => url('user-profile-forum-topics', ['user' => $profileUser->user_id, 'page' => Pagination::param()]),
+            'title' => $profileUser->getUsername() . ' / topics',
+            'canonical_url' => url('user-profile-forum-topics', ['user' => $profileUser->getId(), 'page' => Pagination::param()]),
             'profile_topics' => $topics,
             'profile_topics_pagination' => $topicsPagination,
         ]);
@@ -393,7 +394,7 @@ switch($profileMode) {
 
     case 'forum-posts':
         $template = 'profile.posts';
-        $postsCount = forum_post_count_user($profileUser->user_id);
+        $postsCount = forum_post_count_user($profileUser->getId());
         $postsPagination = new Pagination($postsCount, 20);
 
         if(!$postsPagination->hasValidOffset()) {
@@ -402,7 +403,7 @@ switch($profileMode) {
         }
 
         $posts = forum_post_listing(
-            $profileUser->user_id,
+            $profileUser->getId(),
             $postsPagination->getOffset(),
             $postsPagination->getRange(),
             false,
@@ -410,8 +411,8 @@ switch($profileMode) {
         );
 
         Template::set([
-            'title' => $profileUser->username . ' / posts',
-            'canonical_url' => url('user-profile-forum-posts', ['user' => $profileUser->user_id, 'page' => Pagination::param()]),
+            'title' => $profileUser->getUsername() . ' / posts',
+            'canonical_url' => url('user-profile-forum-posts', ['user' => $profileUser->getId(), 'page' => Pagination::param()]),
             'profile_posts' => $posts,
             'profile_posts_pagination' => $postsPagination,
         ]);
@@ -422,7 +423,7 @@ switch($profileMode) {
         $warnings = $viewingAsGuest
             ? []
             : user_warning_fetch(
-                $profileUser->user_id,
+                $profileUser->getId(),
                 90,
                 $canManageWarnings
                     ? MSZ_WARN_TYPES_VISIBLE_TO_STAFF
diff --git a/public/settings/account.php b/public/settings/account.php
index 3e4ffade..153a1e85 100644
--- a/public/settings/account.php
+++ b/public/settings/account.php
@@ -14,7 +14,7 @@ if(!user_session_active()) {
 
 $errors = [];
 $currentUserId = user_session_current('user_id');
-$currentUser = User::get($currentUserId);
+$currentUser = User::byId($currentUserId);
 $currentEmail = user_email_get($currentUserId);
 $isRestricted = user_warning_check_restriction($currentUserId);
 $twoFactorInfo = user_totp_info($currentUserId);
diff --git a/public/settings/data.php b/public/settings/data.php
index 2ebe0833..0bb988b0 100644
--- a/public/settings/data.php
+++ b/public/settings/data.php
@@ -27,7 +27,7 @@ function db_to_zip(ZipArchive $archive, int $userId, string $filename, string $q
 
 $errors = [];
 $currentUserId = user_session_current('user_id');
-$currentUser = User::get($currentUserId);
+$currentUser = User::byId($currentUserId);
 
 if(isset($_POST['action']) && is_string($_POST['action'])) {
     if(isset($_POST['password']) && is_string($_POST['password'])
diff --git a/public/user-assets.php b/public/user-assets.php
index ce398d35..fc52d4ac 100644
--- a/public/user-assets.php
+++ b/public/user-assets.php
@@ -3,15 +3,20 @@ namespace Misuzu;
 
 use Misuzu\Imaging\Image;
 use Misuzu\Users\User;
+use Misuzu\Users\UserNotFoundException;
 
 $userAssetsMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
 $misuzuBypassLockdown = $userAssetsMode === 'avatar';
 
 require_once '../misuzu.php';
 
-$userInfo = User::get((int)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT));
-$userExists = empty($userExists);
-$userId = $userExists ? $userInfo->getUserId() : 0;
+try {
+    $userInfo = User::byId((int)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT));
+    $userExists = true;
+} catch(UserNotFoundException $ex) {
+    $userExists = false;
+}
+$userId = $userExists ? $userInfo->getId() : 0;
 
 $canViewImages = !$userExists
     || !user_warning_check_expiration($userId, MSZ_WARN_BAN)
diff --git a/src/Comments/CommentsCategory.php b/src/Comments/CommentsCategory.php
new file mode 100644
index 00000000..59bc0c2f
--- /dev/null
+++ b/src/Comments/CommentsCategory.php
@@ -0,0 +1,160 @@
+<?php
+namespace Misuzu\Comments;
+
+use JsonSerializable;
+use Misuzu\DB;
+use Misuzu\Memoizer;
+use Misuzu\Pagination;
+use Misuzu\Users\User;
+
+class CommentsCategoryException extends CommentsException {};
+class CommentsCategoryNotFoundException extends CommentsCategoryException {};
+
+class CommentsCategory implements JsonSerializable {
+    // Database fields
+    private $category_id = -1;
+    private $category_name = '';
+    private $category_created = null;
+    private $category_locked = null;
+
+    private $postCount = -1;
+
+    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`'
+        . ', 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 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 jsonSerialize() {
+        return [
+            'id'      => $this->getId(),
+            'name'    => $this->getName(),
+            'created' => ($created = $this->getCreatedTime()) < 0 ? null : date('c', $created),
+            'locked'  => ($locked  = $this->getLockedTime())  < 0 ? null : date('c', $locked),
+        ];
+    }
+
+    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 getMemoizer() {
+        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::getMemoizer()->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::getMemoizer()->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);
+    }
+}
\ No newline at end of file
diff --git a/src/Comments/CommentsException.php b/src/Comments/CommentsException.php
new file mode 100644
index 00000000..cd7f45a0
--- /dev/null
+++ b/src/Comments/CommentsException.php
@@ -0,0 +1,6 @@
+<?php
+namespace Misuzu\Comments;
+
+use Exception;
+
+class CommentsException extends Exception {}
diff --git a/src/Comments/CommentsParser.php b/src/Comments/CommentsParser.php
new file mode 100644
index 00000000..34b9e3fd
--- /dev/null
+++ b/src/Comments/CommentsParser.php
@@ -0,0 +1,59 @@
+<?php
+namespace Misuzu\Comments;
+
+use Misuzu\DB;
+use Misuzu\Users\User;
+
+class CommentsParser {
+    private const MARKUP_USERNAME = '#\B(?:@{1}(' . MSZ_USERNAME_REGEX . '))#u';
+    private const MARKUP_USERID = '#\B(?:@{2}([0-9]+))#u';
+
+    public static function parseForStorage(string $text): string {
+        return preg_replace_callback(self::MARKUP_USERNAME, function ($matches) {
+            return ($userId = user_id_from_username($matches[1])) < 1
+                ? $matches[0] : "@@{$userId}";
+        }, $text);
+    }
+
+    public static function parseForDisplay(string $text): string {
+        $text = htmlentities($text);
+
+        $text = preg_replace_callback(
+            '/(^|[\n ])([\w]*?)([\w]*?:\/\/[\w]+[^ \,\"\n\r\t<]*)/is',
+            function ($matches) {
+                $matches[0] = trim($matches[0]);
+                $url = parse_url($matches[0]);
+                if(empty($url['scheme']) || !in_array(mb_strtolower($url['scheme']), ['http', 'https'], true))
+                    return $matches[0];
+                return sprintf(' <a href="%1$s" class="link" target="_blank" rel="noreferrer noopener">%1$s</a>', $matches[0]);
+            },
+            $text
+        );
+
+        $text = preg_replace_callback(self::MARKUP_USERID, function ($matches) {
+            $getInfo = DB::prepare('
+                SELECT
+                    u.`user_id`, u.`username`,
+                    COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour`
+                FROM `msz_users` as u
+                LEFT JOIN `msz_roles` as r
+                ON u.`display_role` = r.`role_id`
+                WHERE `user_id` = :user_id
+            ');
+            $getInfo->bind('user_id', $matches[1]);
+            $info = $getInfo->fetch();
+
+            if(empty($info))
+                return $matches[0];
+
+            return sprintf(
+                '<a href="%s" class="comment__mention", style="%s">@%s</a>',
+                url('user-profile', ['user' => $info['user_id']]),
+                html_colour($info['user_colour']),
+                $info['username']
+            );
+        }, $text);
+
+        return nl2br($text);
+    }
+}
diff --git a/src/Comments/CommentsPost.php b/src/Comments/CommentsPost.php
new file mode 100644
index 00000000..ad8f3235
--- /dev/null
+++ b/src/Comments/CommentsPost.php
@@ -0,0 +1,349 @@
+<?php
+namespace Misuzu\Comments;
+
+use JsonSerializable;
+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 implements JsonSerializable {
+    // 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->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->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 jsonSerialize() {
+        $json = [
+            'id'       => $this->getId(),
+            'category' => $this->getCategoryId(),
+            'user'     => $this->getUserId(),
+            'parent'   => ($parent = $this->getParentId()) < 1 ? null : $parent,
+            'text'     => $this->getText(),
+            'created'  => ($created = $this->getCreatedTime()) < 0 ? null : date('c', $created),
+            'pinned'   => ($pinned  = $this->getPinnedTime())  < 0 ? null : date('c', $pinned),
+            'edited'   => ($edited  = $this->getEditedTime())  < 0 ? null : date('c', $edited),
+            'deleted'  => ($deleted = $this->getDeletedTime()) < 0 ? null : date('c', $deleted),
+        ];
+
+        if(($likes = $this->getLikes()) >= 0)
+            $json['likes'] = $likes;
+        if(($dislikes = $this->getDislikes()) >= 0)
+            $json['dislikes'] = $dislikes;
+
+        if($this->hasUserVote())
+            $json['user_vote'] = $this->getUserVote();
+
+        return $json;
+    }
+
+    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/CommentsVote.php b/src/Comments/CommentsVote.php
new file mode 100644
index 00000000..0347febc
--- /dev/null
+++ b/src/Comments/CommentsVote.php
@@ -0,0 +1,246 @@
+<?php
+namespace Misuzu\Comments;
+
+use JsonSerializable;
+use Misuzu\DB;
+use Misuzu\Pagination;
+use Misuzu\Users\User;
+
+class CommentsVoteException extends CommentsException {}
+class CommentsVoteCountFailedException extends CommentsVoteException {}
+class CommentsVoteCreateFailedException extends CommentsVoteException {}
+
+class CommentsVoteCount implements JsonSerializable {
+    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;
+    }
+
+    public function jsonSerialize() {
+        return [
+            'id'       => $this->getPostId(),
+            'likes'    => $this->getLikes(),
+            'dislikes' => $this->getDislikes(),
+            'total'    => $this->getTotal(),
+        ];
+    }
+}
+
+class CommentsVote implements JsonSerializable {
+    // 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 function jsonSerialize() {
+        return [
+            'post' => $this->getPostId(),
+            'user' => $this->getUserId(),
+            'vote' => $this->getVote(),
+        ];
+    }
+
+    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 CommentsVote::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 static;
+        $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/DB.php b/src/DB.php
index 982ee603..a07cb1f4 100644
--- a/src/DB.php
+++ b/src/DB.php
@@ -8,7 +8,6 @@ final class DB {
     private static $instance;
 
     public const PREFIX = 'msz_';
-    public const QUERY_SELECT = 'SELECT %2$s FROM `' . self::PREFIX . '%1$s` AS %1$s';
 
     public const ATTRS = [
         PDO::ATTR_CASE => PDO::CASE_NATURAL,
@@ -16,11 +15,8 @@ final class DB {
         PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL,
         PDO::ATTR_STRINGIFY_FETCHES => false,
         PDO::ATTR_EMULATE_PREPARES => false,
-        PDO::MYSQL_ATTR_INIT_COMMAND => "
-            SET SESSION
-                sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION',
-                time_zone = '+00:00';
-        ",
+        PDO::MYSQL_ATTR_INIT_COMMAND => 'SET SESSION time_zone = \'+00:00\''
+            . ', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\'',
     ];
 
     public static function init(...$args) {
diff --git a/src/Debug/Stopwatch.php b/src/Debug/Stopwatch.php
index 22580101..29a9d669 100644
--- a/src/Debug/Stopwatch.php
+++ b/src/Debug/Stopwatch.php
@@ -1,54 +1,63 @@
 <?php
 namespace Misuzu\Debug;
 
-class Stopwatch {
+final class Stopwatch {
     private $startTime = 0;
     private $stopTime = 0;
     private $laps = [];
 
     private static $instance = null;
 
-    public static function __callStatic(string $name, array $args) {
-        if(self::$instance === null)
-            self::$instance = new static;
-        return self::$instance->{substr($name, 1)}(...$args);
+    public function __call(string $name, array $args) {
+        if($name[0] === '_')
+            return null;
+        return $this->{'_' . $name}(...$args);
     }
 
-    public function __construct() {}
+    public static function __callStatic(string $name, array $args) {
+        if($name[0] === '_')
+            return null;
+        if(self::$instance === null)
+            self::$instance = new static;
+        return self::$instance->{'_' . $name}(...$args);
+    }
 
     private static function time() {
         return microtime(true);
     }
 
-    public function start(): void {
+    public function _start(): void {
         $this->startTime = self::time();
     }
 
-    public function lap(string $text): void {
+    public function _lap(string $text): void {
         $this->laps[$text] = self::time();
     }
 
-    public function stop(): void {
+    public function _stop(): void {
         $this->stopTime = self::time();
     }
 
-    public function reset(): void {
+    public function _reset(): void {
         $this->laps = [];
         $this->startTime = 0;
         $this->stopTime = 0;
     }
 
-    public function elapsed(): float {
+    public function _elapsed(): float {
         return $this->stopTime - $this->startTime;
     }
 
-    public function laps(): array {
+    public function _laps(): array {
         $laps = [];
-
-        foreach($this->laps as $name => $time) {
+        foreach($this->laps as $name => $time)
             $laps[$name] = $time - $this->startTime;
-        }
-
         return $laps;
     }
+
+    public function _dump(bool $trimmed = false): void {
+        header('X-Misuzu-Elapsed: ' . $this->_elapsed());
+        foreach($this->_laps() as $text => $time)
+            header('X-Misuzu-Lap: ' . ($trimmed ? number_format($time, 6) : $time) . ' ' . $text, false);
+    }
 }
diff --git a/src/Http/Handlers/NewsHandler.php b/src/Http/Handlers/NewsHandler.php
index a0a24699..1d3b58d9 100644
--- a/src/Http/Handlers/NewsHandler.php
+++ b/src/Http/Handlers/NewsHandler.php
@@ -15,6 +15,8 @@ use Misuzu\News\NewsPost;
 use Misuzu\News\NewsCategoryNotFoundException;
 use Misuzu\News\NewsPostNotException;
 use Misuzu\Parsers\Parser;
+use Misuzu\Users\User;
+use Misuzu\Users\UserNotFoundException;
 
 final class NewsHandler extends Handler {
     public function index(HttpResponse $response, HttpRequest $request) {
@@ -42,11 +44,9 @@ final class NewsHandler extends Handler {
         if(!$categoryPagination->hasValidOffset())
             return 404;
 
-        $posts = NewsPost::byCategory($categoryInfo, $categoryPagination);
-
         $response->setTemplate('news.category', [
             'category_info' => $categoryInfo,
-            'posts' => $posts,
+            'posts' => $categoryInfo->posts($categoryPagination),
             'news_pagination' => $categoryPagination,
         ]);
     }
@@ -63,12 +63,16 @@ final class NewsHandler extends Handler {
 
         $postInfo->ensureCommentsSection();
         $commentsInfo = $postInfo->getCommentSection();
+        try {
+            $commentsUser = User::byId(user_session_current('user_id', 0));
+        } catch(UserNotFoundException $ex) {
+            $commentsUser = null;
+        }
 
         $response->setTemplate('news.post', [
             'post_info' => $postInfo,
-            'comments_perms' => comments_get_perms(user_session_current('user_id', 0)),
-            'comments_category' => $commentsInfo,
-            'comments' => comments_category_get($commentsInfo['category_id'], user_session_current('user_id', 0)),
+            'comments_info'  => $commentsInfo,
+            'comments_user'  => $commentsUser,
         ]);
 
     }
@@ -76,7 +80,7 @@ final class NewsHandler extends Handler {
     private function createFeed(string $feedMode, ?NewsCategory $categoryInfo, array $posts): Feed {
         $hasCategory = !empty($categoryInfo);
         $pagination = new Pagination(10);
-        $posts = $hasCategory ? NewsPost::byCategory($categoryInfo, $pagination) : NewsPost::all($pagination, true);
+        $posts = $hasCategory ? $categoryInfo->posts($pagination) : NewsPost::all($pagination, true);
 
         $feed = (new Feed)
             ->setTitle(Config::get('site.name', Config::TYPE_STR, 'Misuzu') . ' ยป ' . ($hasCategory ? $categoryInfo->getName() : 'Featured News'))
@@ -132,7 +136,7 @@ final class NewsHandler extends Handler {
 
         $response->setContentType('application/atom+xml; charset=utf-8');
         return (new AtomFeedSerializer)->serializeFeed(
-            self::createFeed('atom', $categoryInfo, NewsPost::byCategory($categoryInfo, new Pagination(10)))
+            self::createFeed('atom', $categoryInfo, $categoryInfo->posts(new Pagination(10)))
         );
     }
 
@@ -145,7 +149,7 @@ final class NewsHandler extends Handler {
 
         $response->setContentType('application/rss+xml; charset=utf-8');
         return (new RssFeedSerializer)->serializeFeed(
-            self::createFeed('rss', $categoryInfo, NewsPost::byCategory($categoryInfo, new Pagination(10)))
+            self::createFeed('rss', $categoryInfo, $categoryInfo->posts(new Pagination(10)))
         );
     }
 
diff --git a/src/Http/Handlers/SockChatHandler.php b/src/Http/Handlers/SockChatHandler.php
index 01414f78..437ed714 100644
--- a/src/Http/Handlers/SockChatHandler.php
+++ b/src/Http/Handlers/SockChatHandler.php
@@ -246,29 +246,29 @@ final class SockChatHandler extends Handler {
         if(!isset($userId) || $userId < 1)
             return ['success' => false, 'reason' => 'unknown'];
 
-        $userInfo = User::get($userId);
+        $userInfo = User::byId($userId);
 
-        if($userInfo === null || !$userInfo->hasUserId())
+        if($userInfo === null)
             return ['success' => false, 'reason' => 'user'];
 
         $perms = self::PERMS_DEFAULT;
 
-        if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_MANAGE_USERS))
+        if(perms_check_user(MSZ_PERMS_USER, $userInfo->getId(), MSZ_PERM_USER_MANAGE_USERS))
             $perms |= self::PERMS_MANAGE_USERS;
-        if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_MANAGE_WARNINGS))
+        if(perms_check_user(MSZ_PERMS_USER, $userInfo->getId(), MSZ_PERM_USER_MANAGE_WARNINGS))
             $perms |= self::PERMS_MANAGE_WARNS;
-        if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_CHANGE_BACKGROUND))
+        if(perms_check_user(MSZ_PERMS_USER, $userInfo->getId(), MSZ_PERM_USER_CHANGE_BACKGROUND))
             $perms |= self::PERMS_CHANGE_BACKG;
-        if(perms_check_user(MSZ_PERMS_FORUM, $userInfo->user_id, MSZ_PERM_FORUM_MANAGE_FORUMS))
+        if(perms_check_user(MSZ_PERMS_FORUM, $userInfo->getId(), MSZ_PERM_FORUM_MANAGE_FORUMS))
             $perms |= self::PERMS_MANAGE_FORUM;
 
         return [
             'success' => true,
-            'user_id' => $userInfo->getUserId(),
+            'user_id' => $userInfo->getId(),
             'username' => $userInfo->getUsername(),
             'colour_raw' => $userInfo->getColourRaw(),
             'hierarchy' => $userInfo->getHierarchy(),
-            'is_silenced' => date('c', user_warning_check_expiration($userInfo->getUserId(), MSZ_WARN_SILENCE)),
+            'is_silenced' => date('c', user_warning_check_expiration($userInfo->getId(), MSZ_WARN_SILENCE)),
             'perms' => $perms,
         ];
     }
diff --git a/src/Memoizer.php b/src/Memoizer.php
new file mode 100644
index 00000000..ffcb3ad7
--- /dev/null
+++ b/src/Memoizer.php
@@ -0,0 +1,27 @@
+<?php
+namespace Misuzu;
+
+use InvalidArgumentException;
+
+class Memoizer {
+    private $collection = [];
+
+    public function find($find, callable $create) {
+        if(is_int($find) || is_string($find)) {
+            if(!isset($this->collection[$find]))
+                $this->collection[$find] = $create();
+            return $this->collection[$find];
+        }
+
+        if(is_callable($find)) {
+            $item = array_find($this->collection, $find) ?? $create();
+            if(method_exists($item, 'getId'))
+                $this->collection[$item->getId()] = $item;
+            else
+                $this->collection[] = $item;
+            return $item;
+        }
+
+        throw new InvalidArgumentException('Wasn\'t able to figure out your $find argument.');
+    }
+}
diff --git a/src/News/NewsCategory.php b/src/News/NewsCategory.php
index 31aa4bc7..e9e2551e 100644
--- a/src/News/NewsCategory.php
+++ b/src/News/NewsCategory.php
@@ -18,7 +18,8 @@ class NewsCategory implements ArrayAccess {
 
     private $postCount = -1;
 
-    private const TABLE = 'news_categories';
+    public const TABLE = 'news_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.`category_description`, %1$s.`category_is_hidden`'
         . ', UNIX_TIMESTAMP(%1$s.`category_created`) AS `category_created`';
 
@@ -94,8 +95,12 @@ class NewsCategory implements ArrayAccess {
         }
     }
 
+    public function posts(?Pagination $pagination = null, bool $includeScheduled = false, bool $includeDeleted = false): array {
+        return NewsPost::byCategory($this, $pagination, $includeScheduled, $includeDeleted);
+    }
+
     private static function countQueryBase(): string {
-        return sprintf(DB::QUERY_SELECT, self::TABLE, sprintf('COUNT(%s.`category_id`)', self::TABLE));
+        return sprintf(self::QUERY_SELECT, sprintf('COUNT(%s.`category_id`)', self::TABLE));
     }
     public static function countAll(bool $showHidden = false): int {
         return (int)DB::prepare(self::countQueryBase()
@@ -104,7 +109,7 @@ class NewsCategory implements ArrayAccess {
     }
 
     private static function byQueryBase(): string {
-        return sprintf(DB::QUERY_SELECT, self::TABLE, sprintf(self::SELECT, self::TABLE));
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
     }
     public static function byId(int $categoryId): self {
         $getCat = DB::prepare(self::byQueryBase() . ' WHERE `category_id` = :cat_id');
diff --git a/src/News/NewsPost.php b/src/News/NewsPost.php
index eff4c6ef..c8a9be14 100644
--- a/src/News/NewsPost.php
+++ b/src/News/NewsPost.php
@@ -3,7 +3,10 @@ namespace Misuzu\News;
 
 use Misuzu\DB;
 use Misuzu\Pagination;
+use Misuzu\Comments\CommentsCategory;
+use Misuzu\Comments\CommentsCategoryNotFoundException;
 use Misuzu\Users\User;
+use Misuzu\Users\UserNotFoundException;
 
 class NewsPostException extends NewsException {};
 class NewsPostNotFoundException extends NewsPostException {};
@@ -24,10 +27,11 @@ class NewsPost {
 
     private $category = null;
     private $user = null;
+    private $userLookedUp = false;
     private $comments = null;
-    private $commentCount = -1;
 
-    private const TABLE = 'news_posts';
+    public const TABLE = 'news_posts';
+    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
     private const SELECT = '%1$s.`post_id`, %1$s.`category_id`, %1$s.`user_id`, %1$s.`comment_section_id`'
         . ', %1$s.`post_is_featured`, %1$s.`post_title`, %1$s.`post_text`'
         . ', UNIX_TIMESTAMP(%1$s.`post_scheduled`) AS `post_scheduled`'
@@ -46,15 +50,17 @@ class NewsPost {
     }
     public function setCategoryId(int $categoryId): self {
         $this->category_id = max(1, $categoryId);
+        $this->category = null;
         return $this;
     }
     public function getCategory(): NewsCategory {
-        if($this->category === null && ($catId = $this->getCategoryId()) > 0)
-            $this->category = NewsCategory::byId($catId);
+        if($this->category === null)
+            $this->category = NewsCategory::byId($this->getCategoryId());
         return $this->category;
     }
     public function setCategory(NewsCategory $category): self {
         $this->category_id = $category->getId();
+        $this->category = $category;
         return $this;
     }
 
@@ -63,41 +69,35 @@ class NewsPost {
     }
     public function setUserId(int $userId): self {
         $this->user_id = $userId < 1 ? null : $userId;
+        $this->user = null;
         return $this;
     }
     public function getUser(): ?User {
-        if($this->user === null && ($userId = $this->getUserId()) > 0)
-            $this->user = User::byId($userId);
+        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->user = $user;
         return $this;
     }
 
     public function getCommentSectionId(): int {
         return $this->comment_section_id < 1 ? -1 : $this->comment_section_id;
     }
-    public function hasCommentsSection(): bool {
+    public function hasCommentSection(): bool {
         return $this->getCommentSectionId() > 0;
     }
-    public function getCommentSection() {
-        if($this->comments === null && ($sectionId = $this->getCommentSectionId()) > 0)
-            $this->comments = comments_category_info($sectionId);
+    public function getCommentSection(): CommentsCategory {
+        if($this->comments === null)
+            $this->comments = CommentsCategory::byId($this->getCommentSectionId());
         return $this->comments;
     }
-    // Temporary solution, should be a method of whatever getCommentSection returns
-    public function getCommentCount(): int {
-        if($this->commentCount < 0)
-            $this->commentCount = (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->getCommentSectionId())->fetchColumn();
-
-        return $this->commentCount;
-    }
 
     public function isFeatured(): bool {
         return $this->post_is_featured !== 0;
@@ -153,24 +153,25 @@ class NewsPost {
         return $this->getDeletedTime() >= 0;
     }
     public function setDeleted(bool $isDeleted): self {
-        $this->post_deleted = $isDeleted ? time() : null;
+        if($this->isDeleted() !== $isDeleted)
+            $this->post_deleted = $isDeleted ? time() : null;
         return $this;
     }
 
     public function ensureCommentsSection(): void {
-        if($this->hasCommentsSection())
+        if($this->hasCommentSection())
             return;
 
-        $this->comments = comments_category_create("news-{$this->getId()}");
+        $this->comments = (new CommentsCategory)
+            ->setName("news-{$this->getId()}");
+        $this->comments->save();
 
-        if($this->comments !== null) {
-            $this->comment_section_id = (int)$this->comments['category_id'];
-            DB::prepare('UPDATE `msz_news_posts` SET `comment_section_id` = :comment_section_id WHERE `post_id` = :post_id')
-                ->execute([
-                    'comment_section_id' => $this->getCommentSectionId(),
-                    'post_id' => $this->getId(),
-                ]);
-        }
+        $this->comment_section_id = $this->comments->getId();
+        DB::prepare('UPDATE `msz_news_posts` SET `comment_section_id` = :comment_section_id WHERE `post_id` = :post_id')
+            ->execute([
+                'comment_section_id' => $this->getCommentSectionId(),
+                'post_id' => $this->getId(),
+            ]);
     }
 
     public function save(): void {
@@ -206,7 +207,7 @@ class NewsPost {
     }
 
     private static function countQueryBase(): string {
-        return sprintf(DB::QUERY_SELECT, self::TABLE, sprintf('COUNT(%s.`post_id`)', self::TABLE));
+        return sprintf(self::QUERY_SELECT, sprintf('COUNT(%s.`post_id`)', self::TABLE));
     }
     public static function countAll(bool $onlyFeatured = false, bool $includeScheduled = false, bool $includeDeleted = false): int {
         return (int)DB::prepare(self::countQueryBase()
@@ -226,7 +227,7 @@ class NewsPost {
     }
 
     private static function byQueryBase(): string {
-        return sprintf(DB::QUERY_SELECT, self::TABLE, sprintf(self::SELECT, self::TABLE));
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
     }
     public static function byId(int $postId): self {
         $post = DB::prepare(self::byQueryBase() . ' WHERE `post_id` = :post_id')
diff --git a/src/Users/User.php b/src/Users/User.php
index ab5cc0a5..70f86f06 100644
--- a/src/Users/User.php
+++ b/src/Users/User.php
@@ -3,9 +3,37 @@ namespace Misuzu\Users;
 
 use Misuzu\Colour;
 use Misuzu\DB;
+use Misuzu\Memoizer;
 use Misuzu\Net\IPAddress;
 
+class UserException extends UsersException {} // this naming definitely won't lead to confusion down the line!
+class UserNotFoundException extends UserException {}
+
 class User {
+    // Database fields
+    // TODO: update all references to use getters and setters and mark all of these as private
+    public $user_id = -1;
+    public $username = '';
+    public $password = '';
+    public $email = '';
+    public $register_ip = '::1';
+    public $last_ip = '::1';
+    public $user_super = 0;
+    public $user_country = 'XX';
+    public $user_colour = null;
+    public $user_created = null;
+    public $user_active = null;
+    public $user_deleted = null;
+    public $display_role = 1;
+    public $user_totp_key = null;
+    public $user_about_content = null;
+    public $user_about_parser = 0;
+    public $user_signature_content = null;
+    public $user_signature_parser = 0;
+    public $user_birthdate = '';
+    public $user_background_settings = 0;
+    public $user_title = null;
+
     private const USER_SELECT = '
         SELECT u.`user_id`, u.`username`, u.`password`, u.`email`, u.`user_super`, u.`user_title`,
                u.`user_country`, u.`user_colour`, u.`display_role`, u.`user_totp_key`,
@@ -50,48 +78,17 @@ class User {
         if($createUser < 1)
             return null;
 
-        return static::get($createUser);
+        return static::byId($createUser);
     }
 
-    public static function get(int $userId): ?User { return self::byId($userId); }
-    public static function byId(int $userId): ?User {
-        return DB::prepare(self::USER_SELECT . 'WHERE `user_id` = :user_id')
-            ->bind('user_id', $userId)
-            ->fetchObject(User::class);
-    }
-
-    public static function findForLogin(string $usernameOrEmail): ?User {
-        return DB::prepare(self::USER_SELECT . 'WHERE LOWER(`email`) = LOWER(:email) OR LOWER(`username`) = LOWER(:username)')
-            ->bind('email', $usernameOrEmail)
-            ->bind('username', $usernameOrEmail)
-            ->fetchObject(User::class);
-    }
-    public static function findForProfile($userId): ?User {
-        return DB::prepare(self::USER_SELECT . 'WHERE `user_id` = :user_id OR LOWER(`username`) = LOWER(:username)')
-            ->bind('user_id', (int)$userId)
-            ->bind('username', (string)$userId)
-            ->fetchObject(User::class);
-    }
-
-    public function hasUserId(): bool { return $this->hasId(); }
-    public function getUserId(): int { return $this->getId(); }
-    public function hasId(): bool {
-        return isset($this->user_id) && $this->user_id > 0;
-    }
     public function getId(): int {
-        return $this->user_id ?? 0;
+        return $this->user_id < 1 ? -1 : $this->user_id;
     }
 
-    public function hasUsername(): bool {
-        return isset($this->username);
-    }
     public function getUsername(): string {
-        return $this->username ?? '';
+        return $this->username;
     }
 
-    public function hasColour(): bool {
-        return isset($this->user_colour);
-    }
     public function getColour(): Colour {
         return new Colour($this->getColourRaw());
     }
@@ -99,8 +96,12 @@ class User {
         return $this->user_colour ?? 0x40000000;
     }
 
+    public function getEmailAddress(): string {
+        return $this->email;
+    }
+
     public function getHierarchy(): int {
-        return $this->hasUserId() ? user_get_hierarchy($this->getUserId()) : 0;
+        return ($userId = $this->getId()) < 1 ? 0 : user_get_hierarchy($userId);
     }
 
     public function hasPassword(): bool {
@@ -113,12 +114,12 @@ class User {
         return password_needs_rehash($this->password, MSZ_USERS_PASSWORD_HASH_ALGO);
     }
     public function setPassword(string $password): void {
-        if(!$this->hasUserId())
+        if(($userId = $this->getId()) < 1)
             return;
 
         DB::prepare('UPDATE `msz_users` SET `password` = :password WHERE `user_id` = :user_id')
             ->bind('password', password_hash($password, MSZ_USERS_PASSWORD_HASH_ALGO))
-            ->bind('user_id', $this->user_id)
+            ->bind('user_id', $userId)
             ->execute();
     }
 
@@ -130,6 +131,9 @@ class User {
         return !empty($this->user_totp_key);
     }
 
+    public function getBackgroundSettings(): int { // Use the below methods instead
+        return $this->user_background_settings;
+    }
     public function getBackgroundAttachment(): int {
         return $this->user_background_settings & 0x0F;
     }
@@ -141,9 +145,70 @@ class User {
     }
 
     public function profileFields(bool $filterEmpty = true): array {
-        if(!$this->hasUserId())
+        if(($userId = $this->getId()) < 1)
             return [];
+        return ProfileField::user($userId, $filterEmpty);
+    }
 
-        return ProfileField::user($this->user_id, $filterEmpty);
+    // TODO: Is this the proper location/implementation for this? (no)
+    private $commentPermsArray = null;
+    public function commentPerms(): array {
+        if($this->commentPermsArray === null)
+            $this->commentPermsArray = perms_check_user_bulk(MSZ_PERMS_COMMENTS, $this->getId(), [
+                'can_comment' => MSZ_PERM_COMMENTS_CREATE,
+                'can_delete' => MSZ_PERM_COMMENTS_DELETE_OWN | MSZ_PERM_COMMENTS_DELETE_ANY,
+                'can_delete_any' => MSZ_PERM_COMMENTS_DELETE_ANY,
+                'can_pin' => MSZ_PERM_COMMENTS_PIN,
+                'can_lock' => MSZ_PERM_COMMENTS_LOCK,
+                'can_vote' => MSZ_PERM_COMMENTS_VOTE,
+            ]);
+        return $this->commentPermsArray;
+    }
+
+    private static function getMemoizer() {
+        static $memoizer = null;
+        if($memoizer === null)
+            $memoizer = new Memoizer;
+        return $memoizer;
+    }
+
+    public static function byId(int $userId): ?User {
+        return self::getMemoizer()->find($userId, function() use ($userId) {
+            $user = DB::prepare(self::USER_SELECT . 'WHERE `user_id` = :user_id')
+                ->bind('user_id', $userId)
+                ->fetchObject(User::class);
+            if(!$user)
+                throw new UserNotFoundException;
+            return $user;
+        });
+    }
+    public static function findForLogin(string $usernameOrEmail): ?User {
+        $usernameOrEmailLower = mb_strtolower($usernameOrEmail);
+        return self::getMemoizer()->find(function() use ($usernameOrEmailLower) {
+            return mb_strtolower($user->getUsername())     === $usernameOrEmailLower
+                || mb_strtolower($user->getEmailAddress()) === $usernameOrEmailLower;
+        }, function() use ($usernameOrEmail) {
+            $user = DB::prepare(self::USER_SELECT . 'WHERE LOWER(`email`) = LOWER(:email) OR LOWER(`username`) = LOWER(:username)')
+                ->bind('email', $usernameOrEmail)
+                ->bind('username', $usernameOrEmail)
+                ->fetchObject(User::class);
+            if(!$user)
+                throw new UserNotFoundException;
+            return $user;
+        });
+    }
+    public static function findForProfile($userIdOrName): ?User {
+        $userIdOrNameLower = mb_strtolower($userIdOrName);
+        return self::getMemoizer()->find(function() use ($userIdOrNameLower) {
+            return $user->getId() == $userIdOrNameLower || mb_strtolower($user->getUsername()) === $userIdOrNameLower;
+        }, function() use ($userIdOrName) {
+            $user = DB::prepare(self::USER_SELECT . 'WHERE `user_id` = :user_id OR LOWER(`username`) = LOWER(:username)')
+                ->bind('user_id', (int)$userIdOrName)
+                ->bind('username', (string)$userIdOrName)
+                ->fetchObject(User::class);
+            if(!$user)
+                throw new UserNotFoundException;
+            return $user;
+        });
     }
 }
diff --git a/src/Users/UsersException.php b/src/Users/UsersException.php
new file mode 100644
index 00000000..15a6a2c9
--- /dev/null
+++ b/src/Users/UsersException.php
@@ -0,0 +1,6 @@
+<?php
+namespace Misuzu\Users;
+
+use Exception;
+
+class UsersException extends Exception {}
diff --git a/src/comments.php b/src/comments.php
deleted file mode 100644
index 5c7220a0..00000000
--- a/src/comments.php
+++ /dev/null
@@ -1,364 +0,0 @@
-<?php
-require_once 'Users/validation.php';
-
-define('MSZ_COMMENTS_VOTE_INDIFFERENT', 0);
-define('MSZ_COMMENTS_VOTE_LIKE', 1);
-define('MSZ_COMMENTS_VOTE_DISLIKE', -1);
-define('MSZ_COMMENTS_VOTE_TYPES', [
-    MSZ_COMMENTS_VOTE_INDIFFERENT,
-    MSZ_COMMENTS_VOTE_LIKE,
-    MSZ_COMMENTS_VOTE_DISLIKE,
-]);
-
-// gets parsed on post
-define('MSZ_COMMENTS_MARKUP_USERNAME', '#\B(?:@{1}(' . MSZ_USERNAME_REGEX . '))#u');
-
-// gets parsed on fetch
-define('MSZ_COMMENTS_MARKUP_USER_ID', '#\B(?:@{2}([0-9]+))#u');
-
-function comments_vote_type_valid(int $voteType): bool {
-    return in_array($voteType, MSZ_COMMENTS_VOTE_TYPES, true);
-}
-
-function comments_parse_for_store(string $text): string {
-    return preg_replace_callback(MSZ_COMMENTS_MARKUP_USERNAME, function ($matches) {
-        return ($userId = user_id_from_username($matches[1])) < 1
-            ? $matches[0]
-            : "@@{$userId}";
-    }, $text);
-}
-
-function comments_parse_for_display(string $text): string {
-    $text = preg_replace_callback(
-        '/(^|[\n ])([\w]*?)([\w]*?:\/\/[\w]+[^ \,\"\n\r\t<]*)/is',
-        function ($matches) {
-            $matches[0] = trim($matches[0]);
-            $url = parse_url($matches[0]);
-
-            if(empty($url['scheme']) || !in_array(mb_strtolower($url['scheme']), ['http', 'https'], true)) {
-                return $matches[0];
-            }
-
-            return sprintf(' <a href="%1$s" class="link" target="_blank" rel="noreferrer noopener">%1$s</a>', $matches[0]);
-        },
-        $text
-    );
-
-    $text = preg_replace_callback(MSZ_COMMENTS_MARKUP_USER_ID, function ($matches) {
-        $getInfo = \Misuzu\DB::prepare('
-            SELECT
-                u.`user_id`, u.`username`,
-                COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour`
-            FROM `msz_users` as u
-            LEFT JOIN `msz_roles` as r
-            ON u.`display_role` = r.`role_id`
-            WHERE `user_id` = :user_id
-        ');
-        $getInfo->bind('user_id', $matches[1]);
-        $info = $getInfo->fetch();
-
-        if(empty($info)) {
-            return $matches[0];
-        }
-
-        return sprintf(
-            '<a href="%s" class="comment__mention", style="%s">@%s</a>',
-            url('user-profile', ['user' => $info['user_id']]),
-            html_colour($info['user_colour']),
-            $info['username']
-        );
-    }, $text);
-
-    return $text;
-}
-
-// usually this is not how you're suppose to handle permission checking,
-// but in the context of comments this is fine since the same shit is used
-// for every comment section.
-function comments_get_perms(int $userId): array {
-    return perms_check_user_bulk(MSZ_PERMS_COMMENTS, $userId, [
-        'can_comment' => MSZ_PERM_COMMENTS_CREATE,
-        'can_delete' => MSZ_PERM_COMMENTS_DELETE_OWN | MSZ_PERM_COMMENTS_DELETE_ANY,
-        'can_delete_any' => MSZ_PERM_COMMENTS_DELETE_ANY,
-        'can_pin' => MSZ_PERM_COMMENTS_PIN,
-        'can_lock' => MSZ_PERM_COMMENTS_LOCK,
-        'can_vote' => MSZ_PERM_COMMENTS_VOTE,
-    ]);
-}
-
-function comments_pin_status(int $comment, bool $mode): ?string {
-    if($comment < 1) {
-        return false;
-    }
-
-    $status = $mode ? date('Y-m-d H:i:s') : null;
-
-    $setPinStatus = \Misuzu\DB::prepare('
-        UPDATE `msz_comments_posts`
-        SET `comment_pinned` = :status
-        WHERE `comment_id` = :comment
-        AND `comment_reply_to` IS NULL
-    ');
-    $setPinStatus->bind('comment', $comment);
-    $setPinStatus->bind('status', $status);
-
-    return $setPinStatus->execute() ? $status : null;
-}
-
-function comments_vote_add(int $comment, int $user, int $vote = MSZ_COMMENTS_VOTE_INDIFFERENT): bool {
-    if(!comments_vote_type_valid($vote)) {
-        return false;
-    }
-
-    $setVote = \Misuzu\DB::prepare('
-        REPLACE INTO `msz_comments_votes`
-            (`comment_id`, `user_id`, `comment_vote`)
-        VALUES
-            (:comment, :user, :vote)
-    ');
-    $setVote->bind('comment', $comment);
-    $setVote->bind('user', $user);
-    $setVote->bind('vote', $vote);
-    return $setVote->execute();
-}
-
-function comments_votes_get(int $commentId): array {
-    $getVotes = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT :id as `id`,
-            (
-                SELECT COUNT(`user_id`)
-                FROM `msz_comments_votes`
-                WHERE `comment_id` = `id`
-                AND `comment_vote` = %1$d
-            ) as `likes`,
-            (
-                SELECT COUNT(`user_id`)
-                FROM `msz_comments_votes`
-                WHERE `comment_id` = `id`
-                AND `comment_vote` = %2$d
-            ) as `dislikes`
-        ',
-        MSZ_COMMENTS_VOTE_LIKE,
-        MSZ_COMMENTS_VOTE_DISLIKE
-    ));
-    $getVotes->bind('id', $commentId);
-    return $getVotes->fetch();
-}
-
-function comments_category_create(string $name): array {
-    $create = \Misuzu\DB::prepare('
-        INSERT INTO `msz_comments_categories`
-            (`category_name`)
-        VALUES
-            (LOWER(:name))
-    ');
-    $create->bind('name', $name);
-    return $create->execute()
-        ? comments_category_info(\Misuzu\DB::lastId(), false)
-        : [];
-}
-
-function comments_category_lock(int $category, bool $lock): void {
-    $setLock = \Misuzu\DB::prepare('
-        UPDATE `msz_comments_categories`
-        SET `category_locked` = IF(:lock, NOW(), NULL)
-        WHERE `category_id` = :category
-    ');
-    $setLock->bind('category', $category);
-    $setLock->bind('lock', $lock);
-    $setLock->execute();
-}
-
-define('MSZ_COMMENTS_CATEGORY_INFO_QUERY', '
-    SELECT
-        `category_id`, `category_locked`
-    FROM `msz_comments_categories`
-    WHERE `%s` = %s
-');
-define('MSZ_COMMENTS_CATEGORY_INFO_ID', sprintf(
-    MSZ_COMMENTS_CATEGORY_INFO_QUERY,
-    'category_id',
-    ':category'
-));
-define('MSZ_COMMENTS_CATEGORY_INFO_NAME', sprintf(
-    MSZ_COMMENTS_CATEGORY_INFO_QUERY,
-    'category_name',
-    'LOWER(:category)'
-));
-
-function comments_category_info($category, bool $createIfNone = false): array {
-    if(is_int($category)) {
-        $getCategory = \Misuzu\DB::prepare(MSZ_COMMENTS_CATEGORY_INFO_ID);
-        $createIfNone = false;
-    } elseif(is_string($category)) {
-        $getCategory = \Misuzu\DB::prepare(MSZ_COMMENTS_CATEGORY_INFO_NAME);
-    } else {
-        return [];
-    }
-
-    $getCategory->bind('category', $category);
-    $categoryInfo = $getCategory->fetch();
-    return $categoryInfo
-        ? $categoryInfo
-        : (
-            $createIfNone
-                ? comments_category_create($category)
-                : []
-        );
-}
-
-define('MSZ_COMMENTS_CATEGORY_QUERY', sprintf(
-    '
-        SELECT
-            p.`comment_id`, p.`comment_text`, p.`comment_reply_to`,
-            p.`comment_created`, p.`comment_pinned`, p.`comment_deleted`,
-            u.`user_id`, u.`username`,
-            COALESCE(u.`user_colour`, r.`role_colour`) AS `user_colour`,
-            (
-                SELECT COUNT(`comment_id`)
-                FROM `msz_comments_votes`
-                WHERE `comment_id` = p.`comment_id`
-                AND `comment_vote` = %1$d
-            ) AS `comment_likes`,
-            (
-                SELECT COUNT(`comment_id`)
-                FROM `msz_comments_votes`
-                WHERE `comment_id` = p.`comment_id`
-                AND `comment_vote` = %2$d
-            ) AS `comment_dislikes`,
-            (
-                SELECT `comment_vote`
-                FROM `msz_comments_votes`
-                WHERE `comment_id` = p.`comment_id`
-                AND `user_id` = :user
-            ) AS `comment_user_vote`
-        FROM `msz_comments_posts` AS p
-        LEFT JOIN `msz_users` AS u
-        ON u.`user_id` = p.`user_id`
-        LEFT JOIN `msz_roles` AS r
-        ON r.`role_id` = u.`display_role`
-        WHERE p.`category_id` = :category
-        %%1$s
-        ORDER BY p.`comment_deleted` ASC, p.`comment_pinned` DESC, p.`comment_id` %%2$s
-    ',
-    MSZ_COMMENTS_VOTE_LIKE,
-    MSZ_COMMENTS_VOTE_DISLIKE
-));
-
-// The $parent param should never be used outside of this function itself and should always remain the last of the list.
-function comments_category_get(int $category, int $user, ?int $parent = null): array {
-    $isParent = $parent === null;
-    $getComments = \Misuzu\DB::prepare(sprintf(
-        MSZ_COMMENTS_CATEGORY_QUERY,
-        $isParent ? 'AND p.`comment_reply_to` IS NULL' : 'AND p.`comment_reply_to` = :parent',
-        $isParent ? 'DESC' : 'ASC'
-    ));
-
-    if(!$isParent) {
-        $getComments->bind('parent', $parent);
-    }
-
-    $getComments->bind('user', $user);
-    $getComments->bind('category', $category);
-    $comments = $getComments->fetchAll();
-
-    $commentsCount = count($comments);
-    for($i = 0; $i < $commentsCount; $i++) {
-        $comments[$i]['comment_html'] = nl2br(comments_parse_for_display(htmlentities($comments[$i]['comment_text'])));
-        $comments[$i]['comment_replies'] = comments_category_get($category, $user, $comments[$i]['comment_id']);
-    }
-
-    return $comments;
-}
-
-function comments_post_create(
-    int $user,
-    int $category,
-    string $text,
-    bool $pinned = false,
-    ?int $reply = null,
-    bool $parse = true
-): int {
-    if($parse) {
-        $text = comments_parse_for_store($text);
-    }
-
-    $create = \Misuzu\DB::prepare('
-        INSERT INTO `msz_comments_posts`
-            (`user_id`, `category_id`, `comment_text`, `comment_pinned`, `comment_reply_to`)
-        VALUES
-            (:user, :category, :text, IF(:pin, NOW(), NULL), :reply)
-    ');
-    $create->bind('user', $user);
-    $create->bind('category', $category);
-    $create->bind('text', $text);
-    $create->bind('pin', $pinned ? 1 : 0);
-    $create->bind('reply', $reply < 1 ? null : $reply);
-    return $create->execute() ? \Misuzu\DB::lastId() : 0;
-}
-
-function comments_post_delete(int $commentId, bool $delete = true): bool {
-    $deleteComment = \Misuzu\DB::prepare('
-        UPDATE `msz_comments_posts`
-        SET `comment_deleted` = IF(:del, NOW(), NULL)
-        WHERE `comment_id` = :id
-    ');
-    $deleteComment->bind('id', $commentId);
-    $deleteComment->bind('del', $delete ? 1 : 0);
-    return $deleteComment->execute();
-}
-
-function comments_post_get(int $commentId, bool $parse = true): array {
-    $fetch = \Misuzu\DB::prepare('
-        SELECT
-            p.`comment_id`, p.`category_id`, p.`comment_text`,
-            p.`comment_created`, p.`comment_edited`, p.`comment_deleted`,
-            p.`comment_reply_to`, p.`comment_pinned`,
-            u.`user_id`, u.`username`,
-            COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour`
-        FROM `msz_comments_posts` as p
-        LEFT JOIN `msz_users` as u
-        ON u.`user_id` = p.`user_id`
-        LEFT JOIN `msz_roles` as r
-        ON r.`role_id` = u.`display_role`
-        WHERE `comment_id` = :id
-    ');
-    $fetch->bind('id', $commentId);
-    $comment = $fetch->fetch();
-
-    if($comment && $parse) {
-        $comment['comment_html'] = nl2br(comments_parse_for_display(htmlentities($comment['comment_text'])));
-    }
-
-    return $comment;
-}
-
-function comments_post_exists(int $commentId): bool {
-    $fetch = \Misuzu\DB::prepare('
-        SELECT COUNT(`comment_id`) > 0
-        FROM `msz_comments_posts`
-        WHERE `comment_id` = :id
-    ');
-    $fetch->bind('id', $commentId);
-    return (bool)$fetch->fetchColumn();
-}
-
-function comments_post_replies(int $commentId): array {
-    $getComments = \Misuzu\DB::prepare('
-        SELECT
-            p.`comment_id`, p.`category_id`, p.`comment_text`,
-            p.`comment_created`, p.`comment_edited`, p.`comment_deleted`,
-            p.`comment_reply_to`, p.`comment_pinned`,
-            u.`user_id`, u.`username`,
-            COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour`
-        FROM `msz_comments_posts` as p
-        LEFT JOIN `msz_users` as u
-        ON u.`user_id` = p.`user_id`
-        LEFT JOIN `msz_roles` as r
-        ON r.`role_id` = u.`display_role`
-        WHERE `comment_reply_to` = :id
-    ');
-    $getComments->bind('id', $commentId);
-    return $getComments->fetchAll();
-}
diff --git a/templates/_layout/comments.twig b/templates/_layout/comments.twig
index 8651dc5f..960e210a 100644
--- a/templates/_layout/comments.twig
+++ b/templates/_layout/comments.twig
@@ -1,4 +1,4 @@
-{% macro comments_input(category, user, perms, reply_to) %}
+{% macro comments_input(category, user, reply_to) %}
     {% set reply_mode = reply_to is not null %}
 
     {% from 'macros.twig' import avatar %}
@@ -6,17 +6,17 @@
 
     <form class="comment comment--input{% if reply_mode %} comment--reply{% endif %}"
         method="post" action="{{ url('comment-create') }}"
-        id="comment-{{ reply_mode ? 'reply-' ~ reply_to.comment_id : 'create-' ~ category.category_id }}">
-        {{ input_hidden('comment[category]', category.category_id) }}
+        id="comment-{{ reply_mode ? 'reply-' ~ reply_to.id : 'create-' ~ category.id }}">
+        {{ input_hidden('comment[category]', category.id) }}
         {{ input_csrf() }}
 
         {% if reply_mode %}
-            {{ input_hidden('comment[reply]', reply_to.comment_id) }}
+            {{ input_hidden('comment[reply]', reply_to.id) }}
         {% endif %}
 
         <div class="comment__container">
             <div class="avatar comment__avatar">
-                {{ avatar(user.user_id, reply_mode ? 40 : 50, user.username) }}
+                {{ avatar(user.id, reply_mode ? 40 : 50, user.username) }}
             </div>
             <div class="comment__content">
                 <textarea
@@ -24,10 +24,10 @@
                     name="comment[text]" placeholder="Share your extensive insights..."></textarea>
                 <div class="comment__actions">
                     {% if not reply_mode %}
-                        {% if perms.can_pin %}
+                        {% if user.commentPerms.can_pin|default(false) %}
                             {{ input_checkbox('comment[pin]', 'Pin this comment', false, 'comment__action') }}
                         {% endif %}
-                        {% if perms.can_lock %}
+                        {% if user.commentPerms.can_lock|default(false) %}
                             {{ input_checkbox('comment[lock]', 'Toggle locked status', false, 'comment__action') }}
                         {% endif %}
                     {% endif %}
@@ -40,110 +40,101 @@
     </form>
 {% endmacro %}
 
-{% macro comments_entry(comment, indent, category, user, perms) %}
+{% macro comments_entry(comment, indent, category, user) %}
     {% from 'macros.twig' import avatar %}
     {% from '_layout/input.twig' import input_checkbox_raw %}
-    {% set is_deleted = comment.comment_deleted is not null %}
-    {% set hide_details = is_deleted and not perms.can_delete_any %}
+    {% set hide_details = comment.userId < 1 or comment.deleted and not user.commentPerms.can_delete_any|default(false) %}
 
-    {% if perms.can_delete_any or (not is_deleted or comment.comment_replies|length > 0) %}
-        {% set is_pinned = comment.comment_pinned is not null %}
-
-        <div class="comment{% if is_deleted %} comment--deleted{% endif %}" id="comment-{{ comment.comment_id }}">
+    {% if user.commentPerms.can_delete_any|default(false) or (not comment.deleted or comment.replies(user)|length > 0) %}
+        <div class="comment{% if comment.deleted %} comment--deleted{% endif %}" id="comment-{{ comment.id }}">
             <div class="comment__container">
                 {% if hide_details %}
                     <div class="comment__avatar">
                         {{ 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.username) }}
+                    <a class="comment__avatar" href="{{ url('user-profile', {'user':comment.user.id}) }}">
+                        {{ avatar(comment.user.id, indent > 1 ? 40 : 50, comment.user.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="{{ comment.user_colour|html_colour }}">{{ comment.username }}</a>
+                                href="{{ url('user-profile', {'user':comment.user.id}) }}"
+                                style="--user-colour: {{ comment.user.colour}}">{{ comment.user.username }}</a>
                         {% endif %}
-                        <a class="comment__link" href="#comment-{{ comment.comment_id }}">
+                        <a class="comment__link" href="#comment-{{ comment.id }}">
                             <time class="comment__date"
-                                title="{{ comment.comment_created|date('r') }}"
-                                datetime="{{ comment.comment_created|date('c') }}">
-                                {{ comment.comment_created|time_diff }}
+                                title="{{ comment.createdTime|date('r') }}"
+                                datetime="{{ comment.createdTime|date('c') }}">
+                                {{ comment.createdTime|time_diff }}
                             </time>
                         </a>
-                        {% if is_pinned %}
+                        {% if comment.pinned %}
                             <span class="comment__pin">{% apply spaceless %}
                                 Pinned
-                                {% if comment.comment_pinned != comment.comment_created %}
-                                    <time title="{{ comment.comment_pinned|date('r') }}"
-                                        datetime="{{ comment.comment_pinned|date('c') }}">
-                                        {{ comment.comment_pinned|time_diff }}
+                                {% if comment.pinnedTime != comment.createdTime %}
+                                    <time title="{{ comment.pinnedTime|date('r') }}"
+                                        datetime="{{ comment.pinnedTime|date('c') }}">
+                                        {{ comment.pinnedTime|time_diff }}
                                     </time>
                                 {% endif %}
                             {% endapply %}</span>
                         {% endif %}
                     </div>
                     <div class="comment__text">
-                        {{ hide_details ? '(deleted)' : (comment.comment_html is defined ? comment.comment_html|raw : comment.comment_text|nl2br) }}
+                        {{ hide_details ? '(deleted)' : comment.parsedText|raw }}
                     </div>
                     <div class="comment__actions">
-                        {% if not is_deleted and user is not null %}
-                            {% if perms.can_vote %}
-                                {% set like_vote_state = comment.comment_user_vote == constant('MSZ_COMMENTS_VOTE_LIKE')
-                                    ? constant('MSZ_COMMENTS_VOTE_INDIFFERENT')
-                                    : constant('MSZ_COMMENTS_VOTE_LIKE') %}
-                                {% set dislike_vote_state = comment.comment_user_vote == constant('MSZ_COMMENTS_VOTE_DISLIKE')
-                                    ? constant('MSZ_COMMENTS_VOTE_INDIFFERENT')
-                                    : constant('MSZ_COMMENTS_VOTE_DISLIKE') %}
+                        {% 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 %}
 
-                                <a class="comment__action comment__action--link comment__action--vote comment__action--like{% if comment.comment_user_vote == constant('MSZ_COMMENTS_VOTE_LIKE') %} comment__action--voted{% endif %}" data-comment-id="{{ comment.comment_id }}" data-comment-vote="{{ like_vote_state }}"
-                                href="{{ url('comment-vote', {'comment':comment.comment_id,'vote':like_vote_state}) }}">
-                                    <!--i class="fas fa-thumbs-up"></i-->
+                                <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 }}"
+                                href="{{ url('comment-vote', {'comment':comment.id,'vote':like_vote_state}) }}">
                                     Like
-                                    {% if comment.comment_likes > 0 %}
-                                        ({{ comment.comment_likes|number_format }})
+                                    {% if comment.likes > 0 %}
+                                        ({{ comment.likes|number_format }})
                                     {% endif %}
                                 </a>
-                                <a class="comment__action comment__action--link comment__action--vote comment__action--dislike{% if comment.comment_user_vote == constant('MSZ_COMMENTS_VOTE_DISLIKE') %} comment__action--voted{% endif %}" data-comment-id="{{ comment.comment_id }}" data-comment-vote="{{ dislike_vote_state }}"
-                                href="{{ url('comment-vote', {'comment':comment.comment_id,'vote':dislike_vote_state}) }}">
-                                    <!--i class="fas fa-thumbs-down"></i-->
+                                <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 }}"
+                                href="{{ url('comment-vote', {'comment':comment.id,'vote':dislike_vote_state}) }}">
                                     Dislike
-                                    {% if comment.comment_dislikes > 0 %}
-                                        ({{ comment.comment_dislikes|number_format }})
+                                    {% if comment.dislikes > 0 %}
+                                        ({{ comment.dislikes|number_format }})
                                     {% endif %}
                                 </a>
                             {% endif %}
-                            {% if perms.can_comment %}
-                                <label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.comment_id }}">Reply</label>
+                            {% if user.commentPerms.can_comment|default(false) %}
+                                <label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.id }}">Reply</label>
                             {% endif %}
-                            {% if perms.can_delete_any or (comment.user_id == user.user_id and perms.can_delete) %}
-                                <a class="comment__action comment__action--link comment__action--hide comment__action--delete" data-comment-id="{{ comment.comment_id }}" href="{{ url('comment-delete', {'comment':comment.comment_id}) }}">Delete</a>
+                            {% if user.commentPerms.can_delete_any|default(false) or (comment.user.id == 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 comment.comment_reply_to is null and perms.can_pin %}
-                                <a class="comment__action comment__action--link comment__action--hide comment__action--pin" data-comment-id="{{ comment.comment_id }}" data-comment-pinned="{{ is_pinned ? '1' : '0' }}" href="{{ url('comment-' ~ (is_pinned ? 'unpin' : 'pin'), {'comment':comment.comment_id}) }}">{{ is_pinned ? 'Unpin' : 'Pin' }}</a>
+                            {% if not comment.hasParent 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 perms.can_delete_any %}
-                            <a class="comment__action comment__action--link comment__action--restore" data-comment-id="{{ comment.comment_id }}" href="{{ url('comment-restore', {'comment':comment.comment_id}) }}">Restore</a>
+                        {% elseif user.commentPerms.can_delete_any|default(false) %}
+                            <a class="comment__action comment__action--link comment__action--restore" data-comment-id="{{ comment.id }}" href="{{ url('comment-restore', {'comment':comment.id}) }}">Restore</a>
                         {% endif %}
                     </div>
                 </div>
             </div>
 
-            <div class="comment__replies comment__replies--indent-{{ indent }}" id="comment-{{ comment.comment_id }}-replies">
+            <div class="comment__replies comment__replies--indent-{{ indent }}" id="comment-{{ comment.id }}-replies">
                 {% from _self import comments_entry, comments_input %}
-                {% if user|default(null) is not null and category|default(null) is not null and perms|default(null) is not null and perms.can_comment %}
-                    {{ input_checkbox_raw('', false, 'comment__reply-toggle', '', false, {'id':'comment-reply-toggle-' ~ comment.comment_id}) }}
-                    {{ comments_input(category, user, perms, comment) }}
+                {% if user|default(null) is not null and category|default(null) is not null and user.commentPerms.can_comment|default(false) %}
+                    {{ input_checkbox_raw('', false, 'comment__reply-toggle', '', false, {'id':'comment-reply-toggle-' ~ comment.id}) }}
+                    {{ comments_input(category, user, comment) }}
                 {% endif %}
-                {% if comment.comment_replies is defined and comment.comment_replies|length > 0 %}
-                    {% for reply in comment.comment_replies %}
-                        {{ comments_entry(reply, indent + 1, category, user, perms) }}
+                {% if comment.replies|length > 0 %}
+                    {% for reply in comment.replies %}
+                        {{ comments_entry(reply, indent + 1, category, user) }}
                     {% endfor %}
                 {% endif %}
             </div>
@@ -151,34 +142,34 @@
     {% endif %}
 {% endmacro %}
 
-{% macro comments_section(comments, category, user, perms) %}
+{% macro comments_section(category, user) %}
     <div class="comments" id="comments">
         <div class="comments__input">
             {% if user|default(null) is null %}
                 <div class="comments__notice">
                     Please <a href="{{ url('auth-login') }}" class="comments__notice__link">login</a> to comment.
                 </div>
-            {% elseif category|default(null) is null or perms|default(null) is null %}
+            {% elseif category|default(null) is null %}
                 <div class="comments__notice">
                     Posting new comments here is disabled.
                 </div>
-            {% elseif not perms.can_lock and category.category_locked is not null %}
+            {% elseif not user.commentPerms.can_lock|default(false) and category.locked %}
                 <div class="comments__notice">
-                    This comment section was locked, <time datetime="{{ category.category_locked|date('c') }}" title="{{ category.category_locked|date('r') }}">{{ category.category_locked|time_diff }}</time>.
+                    This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_diff }}</time>.
                 </div>
-            {% elseif not perms.can_comment %}
+            {% elseif not user.commentPerms.can_comment|default(false) %}
                 <div class="comments__notice">
                     You are not allowed to post comments.
                 </div>
             {% else %}
                 {% from _self import comments_input %}
-                {{ comments_input(category, user, perms) }}
+                {{ comments_input(category, user) }}
             {% endif %}
         </div>
 
-        {% if perms.can_lock and category.category_locked is not null %}
+        {% if user.commentPerms.can_lock|default(false) and category.locked %}
             <div class="comments__notice comments__notice--staff">
-                This comment section was locked, <time datetime="{{ category.category_locked|date('c') }}" title="{{ category.category_locked|date('r') }}">{{ category.category_locked|time_diff }}</time>.
+                This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_diff }}</time>.
             </div>
         {% endif %}
 
@@ -189,13 +180,13 @@
         </noscript>
 
         <div class="comments__listing">
-            {% if comments|length > 0 %}
+            {% if category.posts|length > 0 %}
                 {% from _self import comments_entry %}
-                {% for comment in comments %}
-                    {{ comments_entry(comment, 1, category, user, perms) }}
+                {% for comment in category.posts(user) %}
+                    {{ comments_entry(comment, 1, category, user) }}
                 {% endfor %}
             {% else %}
-                <div class="comments__none" id="_no_comments_notice_{{ category.category_id }}">
+                <div class="comments__none" id="_no_comments_notice_{{ category.id }}">
                     There are no comments yet.
                 </div>
             {% endif %}
diff --git a/templates/changelog/change.twig b/templates/changelog/change.twig
index 85e73e4f..9ffa8525 100644
--- a/templates/changelog/change.twig
+++ b/templates/changelog/change.twig
@@ -83,10 +83,10 @@
         </div>
     </div>
 
-    {% if comments is defined %}
+    {% if comments_category is defined %}
         <div class="container">
             {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments for ' ~ change.change_date) }}
-            {{ comments_section(comments, comments_category, current_user|default(null), comments_perms) }}
+            {{ comments_section(comments_category, comments_user) }}
         </div>
     {% endif %}
 {% endblock %}
diff --git a/templates/changelog/index.twig b/templates/changelog/index.twig
index f82d77f6..000fcca6 100644
--- a/templates/changelog/index.twig
+++ b/templates/changelog/index.twig
@@ -34,10 +34,10 @@
         {% endif %}
     </div>
 
-    {% if comments is defined %}
+    {% if comments_category is defined %}
         <div class="container">
             {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }}
-            {{ comments_section(comments, comments_category, current_user|default(null), comments_perms) }}
+            {{ comments_section(comments_category, comments_user) }}
         </div>
     {% endif %}
 {% endblock %}
diff --git a/templates/news/macros.twig b/templates/news/macros.twig
index 8623c46c..d890f5fe 100644
--- a/templates/news/macros.twig
+++ b/templates/news/macros.twig
@@ -38,7 +38,7 @@
             <div class="news__preview__links">
                 <a href="{{ url('news-post', {'post': post.id}) }}" class="news__preview__link">Continue reading</a>
                 <a href="{{ url('news-post-comments', {'post': post.id}) }}" class="news__preview__link">
-                    {{ post.commentCount < 1 ? 'No' : post.commentCount|number_format }} comment{{ post.commentCount != 1 ? 's' : '' }}
+                    {{ not post.hasCommentSection or post.commentSection.postCount < 1 ? 'No' : post.commentSection.postCount|number_format }} comment{{ not post.hasCommentSection or post.commentSection.postCount != 1 ? 's' : '' }}
                 </a>
             </div>
         </div>
diff --git a/templates/news/post.twig b/templates/news/post.twig
index b410484a..1e2991f9 100644
--- a/templates/news/post.twig
+++ b/templates/news/post.twig
@@ -10,10 +10,10 @@
 {% block content %}
     {{ news_post(post_info) }}
 
-    {% if comments is defined %}
+    {% if comments_info is defined %}
         <div class="container">
             {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }}
-            {{ comments_section(comments, comments_category, current_user|default(null), comments_perms) }}
+            {{ comments_section(comments_info, comments_user) }}
         </div>
     {% endif %}
 {% endblock %}
diff --git a/templates/profile/index.twig b/templates/profile/index.twig
index 807e6374..35999c5f 100644
--- a/templates/profile/index.twig
+++ b/templates/profile/index.twig
@@ -4,7 +4,7 @@
 {% from '_layout/input.twig' import input_hidden, input_csrf, input_text, input_checkbox, input_file, input_file_raw, input_select %}
 
 {% if profile_user is defined %}
-    {% set canonical_url = url('user-profile', {'user': profile_user.user_id}) %}
+    {% set canonical_url = url('user-profile', {'user': profile_user.id}) %}
     {% set title = profile_user.username %}
 {% else %}
     {% set title = 'User not found!' %}
@@ -12,7 +12,7 @@
 
 {% block content %}
     {% if profile_is_editing %}
-        <form class="profile" method="post" action="{{ url('user-profile', {'user': profile_user.user_id}) }}" enctype="multipart/form-data">
+        <form class="profile" method="post" action="{{ url('user-profile', {'user': profile_user.id}) }}" enctype="multipart/form-data">
             {{ input_csrf('profile') }}
 
             {% if perms.edit_avatar %}
@@ -20,7 +20,7 @@
 
                 <script>
                     function updateAvatarPreview(name, url, preview) {
-                        url = url || "{{ url('user-avatar', {'user': profile_user.user_id, 'res': 240})|raw }}";
+                        url = url || "{{ url('user-avatar', {'user': profile_user.id, 'res': 240})|raw }}";
                         preview = preview || document.getElementById('avatar-preview');
                         preview.src = url;
                         preview.title = name;
@@ -211,7 +211,7 @@
 
                         {% if profile_warnings|length > 0 or profile_warnings_can_manage %}
                             <div class="container profile__container profile__warning__container" id="account-standing">
-                                {{ container_title('Account Standing', false, profile_warnings_can_manage ? url('manage-users-warnings', {'user': profile_user.user_id}) : '') }}
+                                {{ container_title('Account Standing', false, profile_warnings_can_manage ? url('manage-users-warnings', {'user': profile_user.id}) : '') }}
 
                                 <div class="profile__warning">
                                     <div class="profile__warning__background"></div>
diff --git a/templates/profile/master.twig b/templates/profile/master.twig
index d0632b7a..e0ed31e5 100644
--- a/templates/profile/master.twig
+++ b/templates/profile/master.twig
@@ -1,8 +1,8 @@
 {% extends 'master.twig' %}
 
 {% if profile_user is defined %}
-    {% set image = url('user-avatar', {'user': profile_user.user_id, 'res': 200}) %}
-    {% set manage_link = url('manage-user', {'user': profile_user.user_id}) %}
+    {% set image = url('user-avatar', {'user': profile_user.id, 'res': 200}) %}
+    {% set manage_link = url('manage-user', {'user': profile_user.id}) %}
     {% set stats = [
         {
             'title': 'Joined',
@@ -17,25 +17,25 @@
         {
             'title': 'Following',
             'value': profile_stats.following_count,
-            'url': url('user-profile-following', {'user': profile_user.user_id}),
+            'url': url('user-profile-following', {'user': profile_user.id}),
             'active': profile_mode == 'following',
         },
         {
             'title': 'Followers',
             'value': profile_stats.followers_count,
-            'url': url('user-profile-followers', {'user': profile_user.user_id}),
+            'url': url('user-profile-followers', {'user': profile_user.id}),
             'active': profile_mode == 'followers',
         },
         {
             'title': 'Topics',
             'value': profile_stats.forum_topic_count,
-            'url': url('user-profile-forum-topics', {'user': profile_user.user_id}),
+            'url': url('user-profile-forum-topics', {'user': profile_user.id}),
             'active': profile_mode == 'forum-topics',
         },
         {
             'title': 'Posts',
             'value': profile_stats.forum_post_count,
-            'url': url('user-profile-forum-posts', {'user': profile_user.user_id}),
+            'url': url('user-profile-forum-posts', {'user': profile_user.id}),
             'active': profile_mode == 'forum-posts',
         },
         {
diff --git a/utility.php b/utility.php
index e2af3b5f..83b16dc8 100644
--- a/utility.php
+++ b/utility.php
@@ -1,32 +1,32 @@
 <?php
 function array_test(array $array, callable $func): bool {
-    foreach($array as $value) {
-        if(!$func($value)) {
+    foreach($array as $value)
+        if(!$func($value))
             return false;
-        }
-    }
-
     return true;
 }
 
 function array_apply(array $array, callable $func): array {
-    for($i = 0; $i < count($array); $i++) {
+    for($i = 0; $i < count($array); ++$i)
         $array[$i] = $func($array[$i]);
-    }
-
     return $array;
 }
 
 function array_bit_or(array $array1, array $array2): array {
-    foreach($array1 as $key => $value) {
+    foreach($array1 as $key => $value)
         $array1[$key] |= $array2[$key] ?? 0;
-    }
-
     return $array1;
 }
 
 function array_rand_value(array $array) {
-    return $array[array_rand($array)];
+    return $array[mt_rand(0, count($array) - 1)];
+}
+
+function array_find(array $array, callable $callback) {
+    foreach($array as $item)
+        if($callback($item))
+            return $item;
+    return null;
 }
 
 function clamp($num, int $min, int $max): int {
@@ -76,72 +76,35 @@ function unique_chars(string $input, bool $multibyte = true): int {
 }
 
 function byte_symbol(int $bytes, bool $decimal = false, array $symbols = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']): string {
-    if($bytes < 1) {
+    if($bytes < 1)
         return '0 B';
-    }
 
     $divider = $decimal ? 1000 : 1024;
     $exp = floor(log($bytes) / log($divider));
-    $bytes = $bytes / pow($divider, floor($exp));
+    $bytes = $bytes / pow($divider, $exp);
     $symbol = $symbols[$exp];
 
     return sprintf("%.2f %s%sB", $bytes, $symbol, $symbol !== '' && !$decimal ? 'i' : '');
 }
 
-// For chat emote list, nuke this when Sharp Chat comms are in this project
-function emotes_list(int $hierarchy = PHP_INT_MAX, bool $unique = false, bool $order = true): array {
-    $getEmotes = \Misuzu\DB::prepare('
-        SELECT    e.`emote_id`, e.`emote_order`, e.`emote_hierarchy`, e.`emote_url`,
-                  s.`emote_string_order`, s.`emote_string`
-        FROM      `msz_emoticons_strings` AS s
-        LEFT JOIN `msz_emoticons` AS e
-        ON        e.`emote_id` = s.`emote_id`
-        WHERE     `emote_hierarchy` <= :hierarchy
-        ORDER BY  IF(:order, e.`emote_order`, e.`emote_id`), s.`emote_string_order`
-    ');
-    $getEmotes->bind('hierarchy', $hierarchy);
-    $getEmotes->bind('order', $order);
-    $emotes = $getEmotes->fetchAll();
-
-    // Removes aliases, emote with lowest ordering is considered the main
-    if($unique) {
-        $existing = [];
-
-        for($i = 0; $i < count($emotes); $i++) {
-            if(in_array($emotes[$i]['emote_url'], $existing)) {
-                unset($emotes[$i]);
-            } else {
-                $existing[] = $emotes[$i]['emote_url'];
-            }
-        }
-    }
-
-    return $emotes;
-}
-
 function safe_delete(string $path): void {
     $path = realpath($path);
-
-    if(empty($path)) {
+    if(empty($path))
         return;
-    }
 
     if(is_dir($path)) {
         rmdir($path);
         return;
     }
 
-    if(is_file($path)) {
+    if(is_file($path))
         unlink($path);
-    }
 }
 
 // mkdir but it fails silently
 function mkdirs(string $path, bool $recursive = false, int $mode = 0777): bool {
-    if(file_exists($path)) {
+    if(file_exists($path))
         return true;
-    }
-
     return mkdir($path, $mode, $recursive);
 }
 
@@ -270,8 +233,8 @@ function html_colour(?int $colour, $attribs = '--user-colour'): string {
     return $css;
 }
 
-function html_avatar(int $userId, int $resolution, string $altText = '', array $attributes = []): string {
-    $attributes['src'] = url('user-avatar', ['user' => $userId, 'res' => $resolution * 2]);
+function html_avatar(?int $userId, int $resolution, string $altText = '', array $attributes = []): string {
+    $attributes['src'] = url('user-avatar', ['user' => $userId ?? 0, 'res' => $resolution * 2]);
     $attributes['alt'] = $altText;
     $attributes['class'] = trim('avatar ' . ($attributes['class'] ?? ''));