diff --git a/assets/css/misuzu/navigation.css b/assets/css/misuzu/navigation.css
index 2dcaf7c1..5f8937ed 100644
--- a/assets/css/misuzu/navigation.css
+++ b/assets/css/misuzu/navigation.css
@@ -3,7 +3,7 @@
     width: 100%;
     display: flex;
     border-width: 0;
-    border-color: var(--text-colour);
+    border-color: #000;
     border-style: solid;
     border-top-width: 1px;
     align-items: flex-start;
@@ -23,7 +23,7 @@
 .navigation__option {
     list-style: none;
     background-color: #c9bbcc;
-    border: 1px solid var(--text-colour);
+    border: 1px solid #000;
     border-top-width: 0;
     flex-grow: 0;
 }
@@ -31,6 +31,7 @@
 .navigation__option--selected {
     background-color: var(--accent-colour);
     top: -1px;
+    padding-bottom: 2px;
 }
 .navigation__option--selected:not(:first-child) {
     margin-left: -1px;
@@ -40,7 +41,7 @@
 .navigation__link {
     display: block;
     padding: 2px 1em;
-    color: var(--text-colour);
+    color: #000;
     text-decoration: none;
 }
 .navigation__link:hover, .navigation__link:focus { color: #609; }
diff --git a/database/2020_06_20_194341_forum_updates.php b/database/2020_06_20_194341_forum_updates.php
new file mode 100644
index 00000000..7795c556
--- /dev/null
+++ b/database/2020_06_20_194341_forum_updates.php
@@ -0,0 +1,66 @@
+<?php
+namespace Misuzu\DatabaseMigrations\ForumUpdates;
+
+use PDO;
+
+function migrate_up(PDO $conn): void {
+    $conn->exec("
+        ALTER TABLE `msz_forum_topics`
+            ADD COLUMN `topic_count_posts` INT(10) UNSIGNED NOT NULL DEFAULT '0' AFTER `topic_title`,
+            ADD COLUMN `topic_post_first` INT(10) UNSIGNED NULL DEFAULT NULL AFTER `topic_count_views`,
+            ADD COLUMN `topic_post_last` INT(10) UNSIGNED NULL DEFAULT NULL AFTER `topic_post_first`,
+            DROP COLUMN `poll_id`,
+            DROP INDEX `posts_poll_id_foreign`,
+            DROP FOREIGN KEY `posts_poll_id_foreign`,
+            ADD INDEX `topics_post_first_foreign` (`topic_post_first`),
+            ADD INDEX `topics_post_last_foreign` (`topic_post_last`),
+            ADD CONSTRAINT `topics_post_first_foreign`
+                FOREIGN KEY (`topic_post_first`)
+                REFERENCES `msz_forum_posts` (`post_id`)
+                ON UPDATE CASCADE
+                ON DELETE SET NULL,
+            ADD CONSTRAINT `topics_post_last_foreign`
+                FOREIGN KEY (`topic_post_last`)
+                REFERENCES `msz_forum_posts` (`post_id`)
+                ON UPDATE CASCADE
+                ON DELETE SET NULL;
+    ");
+
+    $conn->exec("
+        ALTER TABLE `msz_forum_polls`
+            ADD COLUMN `topic_id` INT(10) UNSIGNED NULL DEFAULT NULL AFTER `poll_id`,
+            ADD INDEX `forum_poll_topic_foreign` (`topic_id`),
+            ADD CONSTRAINT `forum_poll_topic_foreign`
+                FOREIGN KEY (`topic_id`)
+                REFERENCES `msz_forum_topics` (`topic_id`)
+                ON UPDATE CASCADE
+                ON DELETE CASCADE;
+    ");
+}
+
+function migrate_down(PDO $conn): void {
+    $conn->exec("
+        ALTER TABLE `msz_forum_polls`
+            DROP COLUMN `topic_id`,
+            DROP INDEX `forum_poll_topic_foreign`,
+            DROP FOREIGN KEY `forum_poll_topic_foreign`;
+    ");
+
+    $conn->exec("
+        ALTER TABLE `msz_forum_topics`
+            ADD COLUMN `poll_id` INT(10) UNSIGNED NULL DEFAULT NULL AFTER `user_id`,
+            DROP COLUMN `topic_count_posts`,
+            DROP COLUMN `topic_post_first`,
+            DROP COLUMN `topic_post_last`,
+            DROP INDEX `topics_post_first_foreign`,
+            DROP INDEX `topics_post_last_foreign`,
+            DROP FOREIGN KEY `topics_post_first_foreign`,
+            DROP FOREIGN KEY `topics_post_last_foreign`,
+            ADD INDEX `posts_poll_id_foreign` (`poll_id`),
+            ADD CONSTRAINT `posts_poll_id_foreign`
+                FOREIGN KEY (`poll_id`)
+                REFERENCES `msz_users` (`poll_id`)
+                ON UPDATE CASCADE
+                ON DELETE SET NULL;
+    ");
+}
diff --git a/misuzu.php b/misuzu.php
index ab574389..b8f30afd 100644
--- a/misuzu.php
+++ b/misuzu.php
@@ -76,12 +76,6 @@ require_once 'src/perms.php';
 require_once 'src/manage.php';
 require_once 'src/url.php';
 require_once 'src/Forum/perms.php';
-require_once 'src/Forum/forum.php';
-require_once 'src/Forum/leaderboard.php';
-require_once 'src/Forum/poll.php';
-require_once 'src/Forum/post.php';
-require_once 'src/Forum/topic.php';
-require_once 'src/Forum/validate.php';
 
 $dbConfig = parse_ini_file(MSZ_CONFIG . '/config.ini', true, INI_SCANNER_TYPED);
 
@@ -123,12 +117,6 @@ if(MSZ_CLI) { // Temporary backwards compatibility measure, remove this later
 // Everything below here should eventually be moved to index.php, probably only initialised when required.
 // Serving things like the css/js doesn't need to initialise sessions.
 
-if(!mb_check_encoding()) {
-    http_response_code(415);
-    echo 'Invalid request encoding.';
-    exit;
-}
-
 ob_start();
 
 if(file_exists(MSZ_ROOT . '/.migrating')) {
diff --git a/public/forum/forum.php b/public/forum/forum.php
index 78b67aae..6b2e29b3 100644
--- a/public/forum/forum.php
+++ b/public/forum/forum.php
@@ -1,81 +1,2 @@
 <?php
-namespace Misuzu;
-
-use Misuzu\Users\User;
-
-require_once '../../misuzu.php';
-
-$forumId = !empty($_GET['f']) && is_string($_GET['f']) ? (int)$_GET['f'] : 0;
-$forumId = max($forumId, 0);
-
-if($forumId === 0) {
-    url_redirect('forum-index');
-    exit;
-}
-
-$forum = forum_get($forumId);
-$forumUser = User::getCurrent();
-$forumUserId = $forumUser === null ? 0 : $forumUser->getId();
-
-if(empty($forum) || ($forum['forum_type'] == MSZ_FORUM_TYPE_LINK && empty($forum['forum_link']))) {
-    echo render_error(404);
-    return;
-}
-
-$perms = forum_perms_get_user($forum['forum_id'], $forumUserId)[MSZ_FORUM_PERMS_GENERAL];
-
-if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) {
-    echo render_error(403);
-    return;
-}
-
-if(isset($forumUser) && $forumUser->hasActiveWarning())
-    $perms &= ~MSZ_FORUM_PERM_SET_WRITE;
-
-Template::set('forum_perms', $perms);
-
-if($forum['forum_type'] == MSZ_FORUM_TYPE_LINK) {
-    forum_increment_clicks($forum['forum_id']);
-    redirect($forum['forum_link']);
-    return;
-}
-
-$forumPagination = new Pagination($forum['forum_topic_count'], 20);
-
-if(!$forumPagination->hasValidOffset() && $forum['forum_topic_count'] > 0) {
-    echo render_error(404);
-    return;
-}
-
-$forumMayHaveTopics = forum_may_have_topics($forum['forum_type']);
-$topics = $forumMayHaveTopics
-    ? forum_topic_listing(
-        $forum['forum_id'],
-        $forumUserId,
-        $forumPagination->getOffset(),
-        $forumPagination->getRange(),
-        perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST),
-        forum_has_priority_voting($forum['forum_type'])
-    )
-    : [];
-
-$forumMayHaveChildren = forum_may_have_children($forum['forum_type']);
-
-if($forumMayHaveChildren) {
-    $forum['forum_subforums'] = forum_get_children($forum['forum_id'], $forumUserId);
-
-    foreach($forum['forum_subforums'] as $skey => $subforum) {
-        $forum['forum_subforums'][$skey]['forum_subforums']
-            = forum_get_children($subforum['forum_id'], $forumUserId);
-    }
-}
-
-Template::render('forum.forum', [
-    'forum_breadcrumbs' => forum_get_breadcrumbs($forum['forum_id']),
-    'global_accent_colour' => forum_get_colour($forum['forum_id']),
-    'forum_may_have_topics' => $forumMayHaveTopics,
-    'forum_may_have_children' => $forumMayHaveChildren,
-    'forum_info' => $forum,
-    'forum_topics' => $topics,
-    'forum_pagination' => $forumPagination,
-]);
+require_once __DIR__ . '/../index.php';
diff --git a/public/forum/index.php b/public/forum/index.php
index e168c337..6b2e29b3 100644
--- a/public/forum/index.php
+++ b/public/forum/index.php
@@ -1,41 +1,2 @@
 <?php
-namespace Misuzu;
-
-use Misuzu\Users\User;
-
-require_once '../../misuzu.php';
-
-$indexMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
-$forumId = !empty($_GET['f']) && is_string($_GET['f']) ? (int)$_GET['f'] : 0;
-
-$currentUser = User::getCurrent();
-$currentUserId = $currentUser === null ? 0 : $currentUser->getId();
-
-switch($indexMode) {
-    case 'mark':
-        url_redirect($forumId < 1 ? 'forum-mark-global' : 'forum-mark-single', ['forum' => $forumId]);
-        break;
-
-    default:
-        $categories = forum_get_root_categories($currentUserId);
-        $blankForum = count($categories) < 1;
-
-        foreach($categories as $key => $category) {
-            $categories[$key]['forum_subforums'] = forum_get_children($category['forum_id'], $currentUserId);
-
-            foreach($categories[$key]['forum_subforums'] as $skey => $sub) {
-                if(!forum_may_have_children($sub['forum_type'])) {
-                    continue;
-                }
-
-                $categories[$key]['forum_subforums'][$skey]['forum_subforums']
-                    = forum_get_children($sub['forum_id'], $currentUserId);
-            }
-        }
-
-        Template::render('forum.index', [
-            'forum_categories' => $categories,
-            'forum_empty' => $blankForum,
-        ]);
-        break;
-}
+require_once __DIR__ . '/../index.php';
diff --git a/public/forum/leaderboard.php b/public/forum/leaderboard.php
index 97eda9b9..24a0cecd 100644
--- a/public/forum/leaderboard.php
+++ b/public/forum/leaderboard.php
@@ -1,6 +1,7 @@
 <?php
 namespace Misuzu;
 
+use Misuzu\Forum\ForumLeaderboard;
 use Misuzu\Users\User;
 
 require_once '../../misuzu.php';
@@ -14,7 +15,7 @@ $leaderboardMode = !empty($_GET['mode']) && is_string($_GET['mode']) && ctype_lo
 $leaderboardId = !empty($_GET['id']) && is_string($_GET['id'])
     && ctype_digit($_GET['id'])
     ? $_GET['id']
-    : MSZ_FORUM_LEADERBOARD_CATEGORY_ALL;
+    : ForumLeaderboard::CATEGORY_ALL;
 $leaderboardIdLength = strlen($leaderboardId);
 
 $leaderboardYear  = $leaderboardIdLength === 4 || $leaderboardIdLength === 6 ? substr($leaderboardId, 0, 4) : null;
@@ -22,8 +23,8 @@ $leaderboardMonth = $leaderboardIdLength === 6 ? substr($leaderboardId, 4, 2) :
 
 $unrankedForums = !empty($_GET['allow_unranked']) ? [] : Config::get('forum_leader.unranked.forum', Config::TYPE_ARR);
 $unrankedTopics = !empty($_GET['allow_unranked']) ? [] : Config::get('forum_leader.unranked.topic', Config::TYPE_ARR);
-$leaderboards = forum_leaderboard_categories();
-$leaderboard = forum_leaderboard_listing($leaderboardYear, $leaderboardMonth, $unrankedForums, $unrankedTopics);
+$leaderboards = ForumLeaderboard::categories();
+$leaderboard = ForumLeaderboard::listing($leaderboardYear, $leaderboardMonth, $unrankedForums, $unrankedTopics);
 
 $leaderboardName = 'All Time';
 
diff --git a/public/forum/poll.php b/public/forum/poll.php
deleted file mode 100644
index db26181f..00000000
--- a/public/forum/poll.php
+++ /dev/null
@@ -1,102 +0,0 @@
-<?php
-namespace Misuzu;
-
-use Misuzu\Users\User;
-
-require_once '../../misuzu.php';
-
-$redirect = !empty($_SERVER['HTTP_REFERER']) && empty($_SERVER['HTTP_X_MISUZU_XHR']) ? $_SERVER['HTTP_REFERER'] : '';
-$isXHR = !$redirect;
-
-if($isXHR) {
-    header('Content-Type: application/json; charset=utf-8');
-} elseif(!is_local_url($redirect)) {
-    echo render_info('Possible request forgery detected.', 403);
-    return;
-}
-
-if(!CSRF::validateRequest()) {
-    echo render_info_or_json($isXHR, "Couldn't verify this request, please refresh the page and try again.", 403);
-    return;
-}
-
-$currentUser = User::getCurrent();
-
-if($currentUser === null) {
-    echo render_info_or_json($isXHR, 'You must be logged in to vote on polls.', 401);
-    return;
-}
-
-$currentUserId = $currentUser->getId();
-
-if($currentUser->isBanned()) {
-    echo render_info_or_json($isXHR, 'You have been banned, check your profile for more information.', 403);
-    return;
-}
-if($currentUser->isSilenced()) {
-    echo render_info_or_json($isXHR, 'You have been silenced, check your profile for more information.', 403);
-    return;
-}
-
-header(CSRF::header());
-
-if(empty($_POST['poll']['id']) || !ctype_digit($_POST['poll']['id'])) {
-    echo render_info_or_json($isXHR, "Invalid request.", 400);
-    return;
-}
-
-$poll = forum_poll_get($_POST['poll']['id']);
-
-if(empty($poll)) {
-    echo "Poll {$poll['poll_id']} doesn't exist.<br>";
-    return;
-}
-
-$topicInfo = forum_poll_get_topic($poll['poll_id']);
-
-if(!is_null($topicInfo['topic_locked'])) {
-    echo "The topic associated with this poll has been locked.<br>";
-    return;
-}
-
-if(!forum_perms_check_user(
-    MSZ_FORUM_PERMS_GENERAL, $topicInfo['forum_id'],
-    $currentUserId, MSZ_FORUM_PERM_SET_READ
-)) {
-    echo "You aren't allowed to vote on this poll.<br>";
-    return;
-}
-
-if($poll['poll_expired']) {
-    echo "Voting for poll {$poll['poll_id']} has closed.<br>";
-    return;
-}
-
-if(!$poll['poll_change_vote'] && forum_poll_has_voted($currentUserId, $poll['poll_id'])) {
-    echo "Can't change vote for {$poll['poll_id']}<br>";
-    return;
-}
-
-$answers = !empty($_POST['poll']['answers'])
-    && is_array($_POST['poll']['answers'])
-    ? $_POST['poll']['answers']
-    : [];
-
-if(count($answers) > $poll['poll_max_votes']) {
-    echo "Too many votes for poll {$poll['poll_id']}<br>";
-    return;
-}
-
-forum_poll_vote_remove($currentUserId, $poll['poll_id']);
-
-foreach($answers as $answerId) {
-    if(!is_string($answerId) || !ctype_digit($answerId)
-        || !forum_poll_validate_option($poll['poll_id'], (int)$answerId)) {
-        echo "Vote {$answerId} was invalid for {$poll['poll_id']}<br>";
-        continue;
-    }
-
-    forum_poll_vote_cast($currentUserId, $poll['poll_id'], (int)$answerId);
-}
-
-url_redirect('forum-topic', ['topic' => $topicInfo['topic_id']]);
diff --git a/public/forum/post.php b/public/forum/post.php
index 4bef354c..fecc9903 100644
--- a/public/forum/post.php
+++ b/public/forum/post.php
@@ -2,6 +2,8 @@
 namespace Misuzu;
 
 use Misuzu\AuditLog;
+use Misuzu\Forum\ForumPost;
+use Misuzu\Forum\ForumPostNotFoundException;
 use Misuzu\Users\User;
 use Misuzu\Users\UserSession;
 
@@ -30,7 +32,7 @@ if(!empty($postMode) && !UserSession::hasCurrent()) {
     return;
 }
 
-$currentUser = User::getCurrent():
+$currentUser = User::getCurrent();
 $currentUserId = $currentUser === null ? 0 : $currentUser->getId();
 
 if(isset($currentUser) && $currentUser->isBanned()) {
@@ -55,59 +57,34 @@ if($isXHR) {
     header(CSRF::header());
 }
 
-$postInfo = forum_post_get($postId, true);
-$perms = empty($postInfo)
-    ? 0
-    : forum_perms_get_user($postInfo['forum_id'], $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
+try {
+    $postInfo = ForumPost::byId($postId);
+    $perms = forum_perms_get_user($postInfo->getCategoryId(), $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
+} catch(ForumPostNotFoundException $ex) {
+    $postInfo = null;
+    $perms = 0;
+}
 
 switch($postMode) {
     case 'delete':
-        $canDelete = forum_post_can_delete($postInfo, $currentUserId);
-        $canDeleteMsg = '';
-        $responseCode = 200;
+        $canDeleteCodes = [
+            'view' => 404,
+            'deleted' => 404,
+            'owner' => 403,
+            'age' => 403,
+            'permission' => 403,
+            '' => 200,
+        ];
+        $canDelete = $postInfo->canBeDeleted($currentUser);
+        $canDeleteMsg = ForumPost::canBeDeletedErrorString($canDelete);
+        $responseCode = $canDeleteCodes[$canDelete] ?? 500;
 
-        switch($canDelete) {
-            case MSZ_E_FORUM_POST_DELETE_USER: // i don't think this is ever reached but we may as well have it
-                $responseCode = 401;
-                $canDeleteMsg = 'You must be logged in to delete posts.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_POST:
-                $responseCode = 404;
-                $canDeleteMsg = "This post doesn't exist.";
-                break;
-            case MSZ_E_FORUM_POST_DELETE_DELETED:
-                $responseCode = 404;
-                $canDeleteMsg = 'This post has already been marked as deleted.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_OWNER:
-                $responseCode = 403;
-                $canDeleteMsg = 'You can only delete your own posts.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_OLD:
-                $responseCode = 401;
-                $canDeleteMsg = 'This post has existed for too long. Ask a moderator to remove if it absolutely necessary.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_PERM:
-                $responseCode = 401;
-                $canDeleteMsg = 'You are not allowed to delete posts.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_OP:
-                $responseCode = 403;
-                $canDeleteMsg = 'This is the opening post of a topic, it may not be deleted without deleting the entire topic as well.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_OK:
-                break;
-            default:
-                $responseCode = 500;
-                $canDeleteMsg = sprintf('Unknown error \'%d\'', $canDelete);
-        }
-
-        if($canDelete !== MSZ_E_FORUM_POST_DELETE_OK) {
+        if($canDelete !== '') {
             if($isXHR) {
                 http_response_code($responseCode);
                 echo json_encode([
                     'success' => false,
-                    'post_id' => $postInfo['post_id'],
+                    'post_id' => $postInfo->getId(),
                     'code' => $canDelete,
                     'message' => $canDeleteMsg,
                 ]);
@@ -121,17 +98,17 @@ switch($postMode) {
         if(!$isXHR) {
             if($postRequestVerified && !$submissionConfirmed) {
                 url_redirect('forum-post', [
-                    'post' => $postInfo['post_id'],
-                    'post_fragment' => 'p' . $postInfo['post_id'],
+                    'post' => $postInfo->getId(),
+                    'post_fragment' => 'p' . $postInfo->getId(),
                 ]);
                 break;
             } elseif(!$postRequestVerified) {
                 Template::render('forum.confirm', [
                     'title' => 'Confirm post deletion',
                     'class' => 'far fa-trash-alt',
-                    'message' => sprintf('You are about to delete post #%d. Are you sure about that?', $postInfo['post_id']),
+                    'message' => sprintf('You are about to delete post #%d. Are you sure about that?', $postInfo->getId()),
                     'params' => [
-                        'p' => $postInfo['post_id'],
+                        'p' => $postInfo->getId(),
                         'm' => 'delete',
                     ],
                 ]);
@@ -139,16 +116,13 @@ switch($postMode) {
             }
         }
 
-        $deletePost = forum_post_delete($postInfo['post_id']);
-
-        if($deletePost) {
-            AuditLog::create(AuditLog::FORUM_POST_DELETE, [$postInfo['post_id']]);
-        }
+        $postInfo->delete();
+        AuditLog::create(AuditLog::FORUM_POST_DELETE, [$postInfo->getId()]);
 
         if($isXHR) {
             echo json_encode([
                 'success' => $deletePost,
-                'post_id' => $postInfo['post_id'],
+                'post_id' => $postInfo->getId(),
                 'message' => $deletePost ? 'Post deleted!' : 'Failed to delete post.',
             ]);
             break;
@@ -159,7 +133,7 @@ switch($postMode) {
             break;
         }
 
-        url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
+        url_redirect('forum-topic', ['topic' => $postInfo->getTopicId()]);
         break;
 
     case 'nuke':
@@ -171,17 +145,17 @@ switch($postMode) {
         if(!$isXHR) {
             if($postRequestVerified && !$submissionConfirmed) {
                 url_redirect('forum-post', [
-                    'post' => $postInfo['post_id'],
-                    'post_fragment' => 'p' . $postInfo['post_id'],
+                    'post' => $postInfo->getId(),
+                    'post_fragment' => 'p' . $postInfo->getId(),
                 ]);
                 break;
             } elseif(!$postRequestVerified) {
                 Template::render('forum.confirm', [
                     'title' => 'Confirm post nuke',
                     'class' => 'fas fa-radiation',
-                    'message' => sprintf('You are about to PERMANENTLY DELETE post #%d. Are you sure about that?', $postInfo['post_id']),
+                    'message' => sprintf('You are about to PERMANENTLY DELETE post #%d. Are you sure about that?', $postInfo->getId()),
                     'params' => [
-                        'p' => $postInfo['post_id'],
+                        'p' => $postInfo->getId(),
                         'm' => 'nuke',
                     ],
                 ]);
@@ -189,18 +163,12 @@ switch($postMode) {
             }
         }
 
-        $nukePost = forum_post_nuke($postInfo['post_id']);
-
-        if(!$nukePost) {
-            echo render_error(500);
-            break;
-        }
-
-        AuditLog::create(AuditLog::FORUM_POST_NUKE, [$postInfo['post_id']]);
+        $postInfo->nuke();
+        AuditLog::create(AuditLog::FORUM_POST_NUKE, [$postInfo->getId()]);
         http_response_code(204);
 
         if(!$isXHR) {
-            url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
+            url_redirect('forum-topic', ['topic' => $postInfo->getTopicId()]);
         }
         break;
 
@@ -213,17 +181,17 @@ switch($postMode) {
         if(!$isXHR) {
             if($postRequestVerified && !$submissionConfirmed) {
                 url_redirect('forum-post', [
-                    'post' => $postInfo['post_id'],
-                    'post_fragment' => 'p' . $postInfo['post_id'],
+                    'post' => $postInfo->getId(),
+                    'post_fragment' => 'p' . $postInfo->getId(),
                 ]);
                 break;
             } elseif(!$postRequestVerified) {
                 Template::render('forum.confirm', [
                     'title' => 'Confirm post restore',
                     'class' => 'fas fa-magic',
-                    'message' => sprintf('You are about to restore post #%d. Are you sure about that?', $postInfo['post_id']),
+                    'message' => sprintf('You are about to restore post #%d. Are you sure about that?', $postInfo->getId()),
                     'params' => [
-                        'p' => $postInfo['post_id'],
+                        'p' => $postInfo->getId(),
                         'm' => 'restore',
                     ],
                 ]);
@@ -231,49 +199,12 @@ switch($postMode) {
             }
         }
 
-        $restorePost = forum_post_restore($postInfo['post_id']);
-
-        if(!$restorePost) {
-            echo render_error(500);
-            break;
-        }
-
-        AuditLog::create(AuditLog::FORUM_POST_RESTORE, [$postInfo['post_id']]);
+        $postInfo->restore();
+        AuditLog::create(AuditLog::FORUM_POST_RESTORE, [$postInfo->getId()]);
         http_response_code(204);
 
         if(!$isXHR) {
-            url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
+            url_redirect('forum-topic', ['topic' => $postInfo->getTopicId()]);
         }
         break;
-
-    default: // function as an alt for topic.php?p= by default
-        $canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
-
-        if(!empty($postInfo['post_deleted']) && !$canDeleteAny) {
-            echo render_error(404);
-            break;
-        }
-
-        $postFind = forum_post_find($postInfo['post_id'], $currentUserId);
-
-        if(empty($postFind)) {
-            echo render_error(404);
-            break;
-        }
-
-        if($canDeleteAny) {
-            $postInfo['preceeding_post_count'] += $postInfo['preceeding_post_deleted_count'];
-        }
-
-        unset($postInfo['preceeding_post_deleted_count']);
-
-        if($isXHR) {
-            echo json_encode($postFind);
-            break;
-        }
-
-        url_redirect('forum-topic', [
-            'topic' => $postFind['topic_id'],
-            'page' => floor($postFind['preceeding_post_count'] / MSZ_FORUM_POSTS_PER_PAGE) + 1,
-        ]);
 }
diff --git a/public/forum/posting.php b/public/forum/posting.php
index 0538d0e7..70326428 100644
--- a/public/forum/posting.php
+++ b/public/forum/posting.php
@@ -1,6 +1,16 @@
 <?php
 namespace Misuzu;
 
+use Misuzu\Forum\ForumCategory;
+use Misuzu\Forum\ForumCategoryNotFoundException;
+use Misuzu\Forum\ForumTopic;
+use Misuzu\Forum\ForumTopicNotFoundException;
+use Misuzu\Forum\ForumTopicCreationFailedException;
+use Misuzu\Forum\ForumTopicUpdateFailedException;
+use Misuzu\Forum\ForumPost;
+use Misuzu\Forum\ForumPostCreationFailedException;
+use Misuzu\Forum\ForumPostUpdateFailedException;
+use Misuzu\Forum\ForumPostNotFoundException;
 use Misuzu\Net\IPAddress;
 use Misuzu\Parsers\Parser;
 use Misuzu\Users\User;
@@ -63,42 +73,37 @@ if(empty($postId) && empty($topicId) && empty($forumId)) {
     return;
 }
 
-if(!empty($postId)) {
-    $post = forum_post_get($postId);
+if(!empty($postId))
+    try {
+        $postInfo = ForumPost::byId($postId);
+        $topicId = $postInfo->getTopicId();
+    } catch(ForumPostNotFoundException $ex) {}
 
-    if(isset($post['topic_id'])) { // should automatic cross-quoting be a thing? if so, check if $topicId is < 1 first
-        $topicId = (int)$post['topic_id'];
-    }
-}
+if(!empty($topicId))
+    try {
+        $topicInfo = ForumTopic::byId($topicId);
+        $forumId = $topicInfo->getCategoryId();
+    } catch(ForumTopicNotFoundException $ex) {}
 
-if(!empty($topicId)) {
-    $topic = forum_topic_get($topicId);
 
-    if(isset($topic['forum_id'])) {
-        $forumId = (int)$topic['forum_id'];
-    }
-}
-
-if(!empty($forumId)) {
-    $forum = forum_get($forumId);
-}
-
-if(empty($forum)) {
+try {
+    $forumInfo = ForumCategory::byId($forumId);
+} catch(ForumCategoryNotFoundException $ex) {
     echo render_error(404);
     return;
 }
 
-$perms = forum_perms_get_user($forum['forum_id'], $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
+$perms = forum_perms_get_user($forumInfo->getId(), $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
 
-if($forum['forum_archived']
-    || (!empty($topic['topic_locked']) && !perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC))
+if($forumInfo->isArchived()
+    || (!empty($topicInfo) && $topicInfo->isLocked() && !perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC))
     || !perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM | MSZ_FORUM_PERM_CREATE_POST)
-    || (empty($topic) && !perms_check($perms, MSZ_FORUM_PERM_CREATE_TOPIC))) {
+    || (empty($topicInfo) && !perms_check($perms, MSZ_FORUM_PERM_CREATE_TOPIC))) {
     echo render_error(403);
     return;
 }
 
-if(!forum_may_have_topics($forum['forum_type'])) {
+if(!$forumInfo->canHaveTopics()) {
     echo render_error(400);
     return;
 }
@@ -106,48 +111,45 @@ if(!forum_may_have_topics($forum['forum_type'])) {
 $topicTypes = [];
 
 if($mode === 'create' || $mode === 'edit') {
-    $topicTypes[MSZ_TOPIC_TYPE_DISCUSSION] = 'Normal discussion';
-
-    if(perms_check($perms, MSZ_FORUM_PERM_STICKY_TOPIC)) {
-        $topicTypes[MSZ_TOPIC_TYPE_STICKY] = 'Sticky topic';
-    }
-    if(perms_check($perms, MSZ_FORUM_PERM_ANNOUNCE_TOPIC)) {
-        $topicTypes[MSZ_TOPIC_TYPE_ANNOUNCEMENT] = 'Announcement';
-    }
-    if(perms_check($perms, MSZ_FORUM_PERM_GLOBAL_ANNOUNCE_TOPIC)) {
-        $topicTypes[MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT] = 'Global Announcement';
-    }
+    $topicTypes[ForumTopic::TYPE_DISCUSSION] = 'Normal discussion';
+    if(perms_check($perms, MSZ_FORUM_PERM_STICKY_TOPIC))
+        $topicTypes[ForumTopic::TYPE_STICKY] = 'Sticky topic';
+    if(perms_check($perms, MSZ_FORUM_PERM_ANNOUNCE_TOPIC))
+        $topicTypes[ForumTopic::TYPE_ANNOUNCEMENT] = 'Announcement';
+    if(perms_check($perms, MSZ_FORUM_PERM_GLOBAL_ANNOUNCE_TOPIC))
+        $topicTypes[ForumTopic::TYPE_GLOBAL_ANNOUNCEMENT] = 'Global Announcement';
 }
 
 // edit mode stuff
 if($mode === 'edit') {
-    if(empty($post)) {
+    if(empty($postInfo)) {
         echo render_error(404);
         return;
     }
 
-    if(!perms_check($perms, $post['poster_id'] === $currentUserId ? MSZ_FORUM_PERM_EDIT_POST : MSZ_FORUM_PERM_EDIT_ANY_POST)) {
+    if(!perms_check($perms, $postInfo->getUserId() === $currentUserId ? MSZ_FORUM_PERM_EDIT_POST : MSZ_FORUM_PERM_EDIT_ANY_POST)) {
         echo render_error(403);
         return;
     }
 }
 
 $notices = [];
+$isNewTopic = false;
 
 if(!empty($_POST)) {
     $topicTitle = $_POST['post']['title'] ?? '';
     $postText = $_POST['post']['text'] ?? '';
     $postParser = (int)($_POST['post']['parser'] ?? Parser::BBCODE);
-    $topicType = isset($_POST['post']['type']) ? (int)$_POST['post']['type'] : null;
+    $topicType = isset($_POST['post']['type']) ? (int)$_POST['post']['type'] : ForumTopic::TYPE_DISCUSSION;
     $postSignature = isset($_POST['post']['signature']);
 
     if(!CSRF::validateRequest()) {
         $notices[] = 'Could not verify request.';
     } else {
-        $isEditingTopic = empty($topic) || ($mode === 'edit' && $post['is_opening_post']);
+        $isEditingTopic = $isNewTopic || ($mode === 'edit' && $postInfo->isOpeningPost());
 
         if($mode === 'create') {
-            $timeoutCheck = max(1, forum_timeout($forumId, $currentUserId));
+            $timeoutCheck = max(1, $forumInfo->checkCooldown($currentUser));
 
             if($timeoutCheck < 5) {
                 $notices[] = sprintf("You're posting too quickly! Please wait %s seconds before posting again.", number_format($timeoutCheck));
@@ -156,20 +158,14 @@ if(!empty($_POST)) {
         }
 
         if($isEditingTopic) {
-            $originalTopicTitle = $topic['topic_title'] ?? null;
+            $originalTopicTitle = $isNewTopic ? null : $topicInfo->getTitle();
             $topicTitleChanged = $topicTitle !== $originalTopicTitle;
-            $originalTopicType = (int)($topic['topic_type'] ?? MSZ_TOPIC_TYPE_DISCUSSION);
+            $originalTopicType =  $isNewTopic ? ForumTopic::TYPE_DISCUSSION : $topicInfo->getType();
             $topicTypeChanged = $topicType !== null && $topicType !== $originalTopicType;
 
-            switch(forum_validate_title($topicTitle)) {
-                case 'too-short':
-                    $notices[] = 'Topic title was too short.';
-                    break;
-
-                case 'too-long':
-                    $notices[] = 'Topic title was too long.';
-                    break;
-            }
+            $validateTopicTitle = ForumTopic::validateTitle($topicTitle);
+            if(!empty($validateTopicTitle))
+                $notices[] = ForumTopic::titleValidationErrorString($validateTopicTitle);
 
             if($mode === 'create' && $topicType === null) {
                 $topicType = array_key_first($topicTypes);
@@ -178,54 +174,52 @@ if(!empty($_POST)) {
             }
         }
 
-        if(!Parser::isValid($postParser)) {
+        if(!Parser::isValid($postParser))
             $notices[] = 'Invalid parser selected.';
-        }
 
-        switch(forum_validate_post($postText)) {
-            case 'too-short':
-                $notices[] = 'Post content was too short.';
-                break;
-
-            case 'too-long':
-                $notices[] = 'Post content was too long.';
-                break;
-        }
+        $postBodyValidation = ForumPost::validateBody($postText);
+        if(!empty($postBodyValidation))
+            $notices[] = ForumPost::bodyValidationErrorString($postBodyValidation);
 
         if(empty($notices)) {
             switch($mode) {
                 case 'create':
-                    if(!empty($topic)) {
-                        forum_topic_bump($topic['topic_id']);
+                    if(!empty($topicInfo)) {
+                        $topicInfo->bumpTopic();
                     } else {
-                        $topicId = forum_topic_create(
-                            $forum['forum_id'],
-                            $currentUserId,
-                            $topicTitle,
-                            $topicType
-                        );
+                        $isNewTopic = true;
+                        $topicInfo = ForumTopic::create($forumInfo, $currentUser, $topicTitle, $topicType);
+                        $topicId = $topicInfo->getId();
                     }
 
-                    $postId = forum_post_create(
-                        $topicId,
-                        $forum['forum_id'],
-                        $currentUserId,
-                        IPAddress::remote(),
-                        $postText,
-                        $postParser,
-                        $postSignature
-                    );
-                    forum_topic_mark_read($currentUserId, $topicId, $forum['forum_id']);
-                    forum_count_increase($forum['forum_id'], empty($topic));
+                    $postInfo = ForumPost::create($topicInfo, $currentUser, IPAddress::remote(), $postText, $postParser, $postSignature);
+                    $postId = $postInfo->getId();
+
+                    $topicInfo->markRead($currentUser);
+                    $forumInfo->increaseTopicPostCount($isNewTopic);
                     break;
 
                 case 'edit':
-                    if(!forum_post_update($postId, IPAddress::remote(), $postText, $postParser, $postSignature, $postText !== $post['post_text'])) {
+                    if($postText !== $postInfo->getBody() && $postInfo->shouldBumpEdited())
+                        $postInfo->bumpEdited();
+
+                    $postInfo->setRemoteAddress(IPAddress::remote())
+                        ->setBody($postText)
+                        ->setBodyParser($postParser)
+                        ->setDisplaySignature($postSignature);
+
+                    try {
+                        $postInfo->update();
+                    } catch(ForumPostUpdateFailedException $ex) {
                         $notices[] = 'Post edit failed.';
                     }
 
                     if($isEditingTopic && ($topicTitleChanged || $topicTypeChanged)) {
-                        if(!forum_topic_update($topicId, $topicTitle, $topicType)) {
+                        $topicInfo->setTitle($topicTitle)->setType($topicType);
+
+                        try {
+                            $topicInfo->update();
+                        } catch(ForumTopicUpdateFailedException $ex) {
                             $notices[] = 'Topic update failed.';
                         }
                     }
@@ -233,7 +227,7 @@ if(!empty($_POST)) {
             }
 
             if(empty($notices)) {
-                $redirect = url(empty($topic) ? 'forum-topic' : 'forum-post', [
+                $redirect = url($isNewTopic ? 'forum-topic' : 'forum-post', [
                     'topic' => $topicId ?? 0,
                     'post' => $postId ?? 0,
                     'post_fragment' => 'p' . ($postId ?? 0),
@@ -245,21 +239,18 @@ if(!empty($_POST)) {
     }
 }
 
-if(!empty($topic)) {
-    Template::set('posting_topic', $topic);
+if(!$isNewTopic && !empty($topicInfo)) {
+    Template::set('posting_topic', $topicInfo);
 }
 
 if($mode === 'edit') { // $post is pretty much sure to be populated at this point
-    Template::set('posting_post', $post);
+    Template::set('posting_post', $postInfo);
 }
 
-$displayInfo = forum_posting_info($currentUserId);
-
 Template::render('forum.posting', [
-    'posting_breadcrumbs' => forum_get_breadcrumbs($forumId),
-    'global_accent_colour' => forum_get_colour($forumId),
-    'posting_forum' => $forum,
-    'posting_info' => $displayInfo,
+    'global_accent_colour' => $forumInfo->getColour(),
+    'posting_forum' => $forumInfo,
+    'posting_user' => $currentUser,
     'posting_notices' => $notices,
     'posting_mode' => $mode,
     'posting_types' => $topicTypes,
diff --git a/public/forum/topic-priority.php b/public/forum/topic-priority.php
deleted file mode 100644
index 3a92e014..00000000
--- a/public/forum/topic-priority.php
+++ /dev/null
@@ -1,55 +0,0 @@
-<?php
-namespace Misuzu;
-
-use Misuzu\Users\User;
-use Misuzu\Users\UserNotFoundException;
-
-require_once '../../misuzu.php';
-
-if(!MSZ_DEBUG)
-    return;
-
-$topicId = !empty($_GET['t']) && is_string($_GET['t']) ? (int)$_GET['t'] : 0;
-$topicUser = User::getCurrent();
-$topicUserId = $topicUser === null ? 0 : $topicUser->getId();
-
-if($topicUserId < 1) {
-    echo render_error(403);
-    return;
-}
-
-$topic = forum_topic_get($topicId, true);
-$perms = $topic
-    ? forum_perms_get_user($topic['forum_id'], $topicUserId)[MSZ_FORUM_PERMS_GENERAL]
-    : 0;
-
-if(isset($topicUser) && $topicUser->hasActiveWarning())
-    $perms &= ~MSZ_FORUM_PERM_SET_WRITE;
-
-$topicIsDeleted = !empty($topic['topic_deleted']);
-$canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
-
-if(!$topic || ($topicIsDeleted && !$canDeleteAny)) {
-    echo render_error(404);
-    return;
-}
-
-if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM, true) // | MSZ_FORUM_PERM_PRIORITY_VOTE
-    || !$canDeleteAny
-    && (
-        !empty($topic['topic_locked'])
-        || !empty($topic['topic_archived'])
-    )
-) {
-    echo render_error(403);
-    return;
-}
-
-if(!forum_has_priority_voting($topic['forum_type'])) {
-    echo render_error(400);
-    return;
-}
-
-forum_topic_priority_increase($topicId, $topicUserId);
-
-url_redirect('forum-topic', ['topic' => $topicId]);
diff --git a/public/forum/topic.php b/public/forum/topic.php
index 690cd47a..cee90e80 100644
--- a/public/forum/topic.php
+++ b/public/forum/topic.php
@@ -2,39 +2,47 @@
 namespace Misuzu;
 
 use Misuzu\AuditLog;
+use Misuzu\Forum\ForumTopic;
+use Misuzu\Forum\ForumTopicNotFoundException;
+use Misuzu\Forum\ForumPost;
+use Misuzu\Forum\ForumPostNotFoundException;
 use Misuzu\Users\User;
 use Misuzu\Users\UserSession;
 
 require_once '../../misuzu.php';
 
-$postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0;
-$topicId = !empty($_GET['t']) && is_string($_GET['t']) ? (int)$_GET['t'] : 0;
-$moderationMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
-$submissionConfirmed = !empty($_GET['confirm']) && is_string($_GET['confirm']) && $_GET['confirm'] === '1';
+$postId              =    (int)filter_input(INPUT_GET, 'p', FILTER_SANITIZE_NUMBER_INT);
+$topicId             =    (int)filter_input(INPUT_GET, 't', FILTER_SANITIZE_NUMBER_INT);
+$moderationMode      = (string)filter_input(INPUT_GET, 'm', FILTER_SANITIZE_STRING);
+$submissionConfirmed =         filter_input(INPUT_GET, 'confirm') === '1';
 
 $topicUser = User::getCurrent();
 $topicUserId = $topicUser === null ? 0 : $topicUser->getId();
 
-if($topicId < 1 && $postId > 0) {
-    $postInfo = forum_post_find($postId, $topicUserId);
-
-    if(!empty($postInfo['topic_id'])) {
-        $topicId = (int)$postInfo['topic_id'];
+if($topicId < 1 && $postId > 0)
+    try {
+        $postInfo = ForumPost::byId($postId);
+        $topicId = $postInfo->getTopicId();
+    } catch(ForumPostNotFoundException $ex) {
+        echo render_error(404);
+        return;
     }
+
+try {
+    $topicInfo = ForumTopic::byId($topicId);
+} catch(ForumTopicNotFoundException $ex) {
+    echo render_error(404);
+    return;
 }
 
-$topic = forum_topic_get($topicId, true);
-$perms = $topic
-    ? forum_perms_get_user($topic['forum_id'], $topicUserId)[MSZ_FORUM_PERMS_GENERAL]
-    : 0;
+$perms = forum_perms_get_user($topicInfo->getCategory()->getId(), $topicUserId)[MSZ_FORUM_PERMS_GENERAL];
 
 if(isset($topicUser) && $topicUser->hasActiveWarning())
     $perms &= ~MSZ_FORUM_PERM_SET_WRITE;
 
-$topicIsDeleted = !empty($topic['topic_deleted']);
 $canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
 
-if(!$topic || ($topicIsDeleted && !$canDeleteAny)) {
+if($topicInfo->isDeleted() && !$canDeleteAny) {
     echo render_error(404);
     return;
 }
@@ -44,29 +52,18 @@ if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) {
     return;
 }
 
-if(!empty($topic['poll_id'])) {
-    $pollOptions = forum_poll_get_options($topic['poll_id']);
-    $pollUserAnswers = forum_poll_get_user_answers($topic['poll_id'], $topicUserId);
-}
-
-if(forum_has_priority_voting($topic['forum_type'])) {
-    $topicPriority = forum_topic_priority($topic['topic_id']);
-}
-
-$topicIsLocked = !empty($topic['topic_locked']);
-$topicIsArchived = !empty($topic['topic_archived']);
-$topicPostsTotal = (int)($topic['topic_count_posts'] + $topic['topic_count_posts_deleted']);
-$topicIsFrozen = $topicIsArchived || $topicIsDeleted;
-$canDeleteOwn = !$topicIsFrozen && !$topicIsLocked && perms_check($perms, MSZ_FORUM_PERM_DELETE_POST);
+$topicPostsTotal = $topicInfo->getActualPostCount(true);
+$topicIsFrozen = $topicInfo->isArchived() || $topicInfo->isDeleted();
+$canDeleteOwn = !$topicIsFrozen && !$topicInfo->isLocked() && perms_check($perms, MSZ_FORUM_PERM_DELETE_POST);
 $canBumpTopic = !$topicIsFrozen && perms_check($perms, MSZ_FORUM_PERM_BUMP_TOPIC);
 $canLockTopic = !$topicIsFrozen && perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC);
-$canNukeOrRestore = $canDeleteAny && $topicIsDeleted;
-$canDelete = !$topicIsDeleted && (
+$canNukeOrRestore = $canDeleteAny && $topicInfo->isDeleted();
+$canDelete = !$topicInfo->isDeleted() && (
     $canDeleteAny || (
         $topicPostsTotal > 0
-        && $topicPostsTotal <= MSZ_FORUM_TOPIC_DELETE_POST_LIMIT
+        && $topicPostsTotal <= ForumTopic::DELETE_POST_LIMIT
         && $canDeleteOwn
-        && $topic['author_user_id'] === $topicUserId
+        && $topicInfo->getUserId() === $topicUserId
     )
 );
 
@@ -109,52 +106,25 @@ if(in_array($moderationMode, $validModerationModes, true)) {
 
     switch($moderationMode) {
         case 'delete':
-            $canDeleteCode = forum_topic_can_delete($topic, $topicUserId);
-            $canDeleteMsg = '';
-            $responseCode = 200;
+            $canDeleteCodes = [
+                'view' => 404,
+                'deleted' => 404,
+                'owner' => 403,
+                'age' => 403,
+                'permission' => 403,
+                'posts' => 403,
+                '' => 200,
+            ];
+            $canDelete = $topicInfo->canBeDeleted($topicUser);
+            $canDeleteMsg = ForumTopic::canBeDeletedErrorString($canDelete);
+            $responseCode = $canDeleteCodes[$canDelete] ?? 500;
 
-            switch($canDeleteCode) {
-                case MSZ_E_FORUM_TOPIC_DELETE_USER:
-                    $responseCode = 401;
-                    $canDeleteMsg = 'You must be logged in to delete topics.';
-                    break;
-                case MSZ_E_FORUM_TOPIC_DELETE_TOPIC:
-                    $responseCode = 404;
-                    $canDeleteMsg = "This topic doesn't exist.";
-                    break;
-                case MSZ_E_FORUM_TOPIC_DELETE_DELETED:
-                    $responseCode = 404;
-                    $canDeleteMsg = 'This topic has already been marked as deleted.';
-                    break;
-                case MSZ_E_FORUM_TOPIC_DELETE_OWNER:
-                    $responseCode = 403;
-                    $canDeleteMsg = 'You can only delete your own topics.';
-                    break;
-                case MSZ_E_FORUM_TOPIC_DELETE_OLD:
-                    $responseCode = 401;
-                    $canDeleteMsg = 'This topic has existed for too long. Ask a moderator to remove if it absolutely necessary.';
-                    break;
-                case MSZ_E_FORUM_TOPIC_DELETE_PERM:
-                    $responseCode = 401;
-                    $canDeleteMsg = 'You are not allowed to delete topics.';
-                    break;
-                case MSZ_E_FORUM_TOPIC_DELETE_POSTS:
-                    $responseCode = 403;
-                    $canDeleteMsg = 'This topic already has replies, you may no longer delete it. Ask a moderator to remove if it absolutely necessary.';
-                    break;
-                case MSZ_E_FORUM_TOPIC_DELETE_OK:
-                    break;
-                default:
-                    $responseCode = 500;
-                    $canDeleteMsg = sprintf('Unknown error \'%d\'', $canDelete);
-            }
-
-            if($canDeleteCode !== MSZ_E_FORUM_TOPIC_DELETE_OK) {
+            if($canDelete !== '') {
                 if($isXHR) {
                     http_response_code($responseCode);
                     echo json_encode([
                         'success' => false,
-                        'topic_id' => $topic['topic_id'],
+                        'topic_id' => $topicInfo->getId(),
                         'code' => $canDeleteCode,
                         'message' => $canDeleteMsg,
                     ]);
@@ -170,9 +140,9 @@ if(in_array($moderationMode, $validModerationModes, true)) {
                     Template::render('forum.confirm', [
                         'title' => 'Confirm topic deletion',
                         'class' => 'far fa-trash-alt',
-                        'message' => sprintf('You are about to delete topic #%d. Are you sure about that?', $topic['topic_id']),
+                        'message' => sprintf('You are about to delete topic #%d. Are you sure about that?', $topicInfo->getId()),
                         'params' => [
-                            't' => $topic['topic_id'],
+                            't' => $topicInfo->getId(),
                             'm' => 'delete',
                         ],
                     ]);
@@ -180,34 +150,26 @@ if(in_array($moderationMode, $validModerationModes, true)) {
                 } elseif(!$submissionConfirmed) {
                     url_redirect(
                         'forum-topic',
-                        ['topic' => $topic['topic_id']]
+                        ['topic' => $topicInfo->getId()]
                     );
                     break;
                 }
             }
 
-            $deleteTopic = forum_topic_delete($topic['topic_id']);
-
-            if($deleteTopic) {
-                AuditLog::create(AuditLog::FORUM_TOPIC_DELETE, [$topic['topic_id']]);
-            }
+            $topicInfo->delete();
+            AuditLog::create(AuditLog::FORUM_TOPIC_DELETE, [$topicInfo->getId()]);
 
             if($isXHR) {
                 echo json_encode([
-                    'success' => $deleteTopic,
-                    'topic_id' => $topic['topic_id'],
-                    'message' => $deleteTopic ? 'Topic deleted!' : 'Failed to delete topic.',
+                    'success' => true,
+                    'topic_id' => $topicInfo->getId(),
+                    'message' => 'Topic deleted!',
                 ]);
                 break;
             }
 
-            if(!$deleteTopic) {
-                echo render_error(500);
-                break;
-            }
-
             url_redirect('forum-category', [
-                'forum' => $topic['forum_id'],
+                'forum' => $topicInfo->getCategoryId(),
             ]);
             break;
 
@@ -222,34 +184,28 @@ if(in_array($moderationMode, $validModerationModes, true)) {
                     Template::render('forum.confirm', [
                         'title' => 'Confirm topic restore',
                         'class' => 'fas fa-magic',
-                        'message' => sprintf('You are about to restore topic #%d. Are you sure about that?', $topic['topic_id']),
+                        'message' => sprintf('You are about to restore topic #%d. Are you sure about that?', $topicInfo->getId()),
                         'params' => [
-                            't' => $topic['topic_id'],
+                            't' => $topicInfo->getId(),
                             'm' => 'restore',
                         ],
                     ]);
                     break;
                 } elseif(!$submissionConfirmed) {
                     url_redirect('forum-topic', [
-                        'topic' => $topic['topic_id'],
+                        'topic' => $topicInfo->getId(),
                     ]);
                     break;
                 }
             }
 
-            $restoreTopic = forum_topic_restore($topic['topic_id']);
-
-            if(!$restoreTopic) {
-                echo render_error(500);
-                break;
-            }
-
-            AuditLog::create(AuditLog::FORUM_TOPIC_RESTORE, [$topic['topic_id']]);
+            $topicInfo->restore();
+            AuditLog::create(AuditLog::FORUM_TOPIC_RESTORE, [$topicInfo->getId()]);
             http_response_code(204);
 
             if(!$isXHR) {
                 url_redirect('forum-category', [
-                    'forum' => $topic['forum_id'],
+                    'forum' => $topicInfo->getCategoryId(),
                 ]);
             }
             break;
@@ -265,124 +221,90 @@ if(in_array($moderationMode, $validModerationModes, true)) {
                     Template::render('forum.confirm', [
                         'title' => 'Confirm topic nuke',
                         'class' => 'fas fa-radiation',
-                        'message' => sprintf('You are about to PERMANENTLY DELETE topic #%d. Are you sure about that?', $topic['topic_id']),
+                        'message' => sprintf('You are about to PERMANENTLY DELETE topic #%d. Are you sure about that?', $topicInfo->getId()),
                         'params' => [
-                            't' => $topic['topic_id'],
+                            't' => $topicInfo->getId(),
                             'm' => 'nuke',
                         ],
                     ]);
                     break;
                 } elseif(!$submissionConfirmed) {
                     url_redirect('forum-topic', [
-                        'topic' => $topic['topic_id'],
+                        'topic' => $topicInfo->getId(),
                     ]);
                     break;
                 }
             }
 
-            $nukeTopic = forum_topic_nuke($topic['topic_id']);
-
-            if(!$nukeTopic) {
-                echo render_error(500);
-                break;
-            }
-
-            AuditLog::create(AuditLog::FORUM_TOPIC_NUKE, [$topic['topic_id']]);
+            $topicInfo->nuke();
+            AuditLog::create(AuditLog::FORUM_TOPIC_NUKE, [$topicInfo->getId()]);
             http_response_code(204);
 
             if(!$isXHR) {
                 url_redirect('forum-category', [
-                    'forum' => $topic['forum_id'],
+                    'forum' => $topicInfo->getCategoryId(),
                 ]);
             }
             break;
 
         case 'bump':
-            if($canBumpTopic && forum_topic_bump($topic['topic_id'])) {
-                AuditLog::create(AuditLog::FORUM_TOPIC_BUMP, [$topic['topic_id']]);
+            if($canBumpTopic) {
+                $topicInfo->bumpTopic();
+                AuditLog::create(AuditLog::FORUM_TOPIC_BUMP, [$topicInfo->getId()]);
             }
 
             url_redirect('forum-topic', [
-                'topic' => $topic['topic_id'],
+                'topic' => $topicInfo->getId(),
             ]);
             break;
 
         case 'lock':
-            if($canLockTopic && !$topicIsLocked && forum_topic_lock($topic['topic_id'])) {
-                AuditLog::create(AuditLog::FORUM_TOPIC_LOCK, [$topic['topic_id']]);
+            if($canLockTopic && !$topicInfo->isLocked()) {
+                $topicInfo->setLocked(true);
+                AuditLog::create(AuditLog::FORUM_TOPIC_LOCK, [$topicInfo->getId()]);
             }
 
             url_redirect('forum-topic', [
-                'topic' => $topic['topic_id'],
+                'topic' => $topicInfo->getId(),
             ]);
             break;
 
         case 'unlock':
-            if($canLockTopic && $topicIsLocked && forum_topic_unlock($topic['topic_id'])) {
-                AuditLog::create(AuditLog::FORUM_TOPIC_UNLOCK, [$topic['topic_id']]);
+            if($canLockTopic && $topicInfo->isLocked()) {
+                $topicInfo->setLocked(false);
+                AuditLog::create(AuditLog::FORUM_TOPIC_UNLOCK, [$topicInfo->getId()]);
             }
 
             url_redirect('forum-topic', [
-                'topic' => $topic['topic_id'],
+                'topic' => $topicInfo->getId(),
             ]);
             break;
     }
     return;
 }
 
-$topicPosts = $topic['topic_count_posts'];
+$topicPagination = new Pagination($topicInfo->getActualPostCount($canDeleteAny), \Misuzu\Forum\ForumPost::PER_PAGE, 'page');
 
-if($canDeleteAny) {
-    $topicPosts += $topic['topic_count_posts_deleted'];
-}
-
-$topicPagination = new Pagination($topicPosts, MSZ_FORUM_POSTS_PER_PAGE, 'page');
-
-if(isset($postInfo['preceeding_post_count'])) {
-    $preceedingPosts = $postInfo['preceeding_post_count'];
-
-    if($canDeleteAny) {
-        $preceedingPosts += $postInfo['preceeding_post_deleted_count'];
-    }
-
-    $topicPagination->setPage(floor($preceedingPosts / $topicPagination->getRange()), true);
-}
+if(isset($postInfo))
+    $topicPagination->setPage($postInfo->getTopicPage($canDeleteAny, $topicPagination->getRange()));
 
 if(!$topicPagination->hasValidOffset()) {
     echo render_error(404);
     return;
 }
 
-Template::set('topic_perms', $perms);
+$canReply = !$topicInfo->isArchived() && !$topicInfo->isLocked() && !$topicInfo->isDeleted() && perms_check($perms, MSZ_FORUM_PERM_CREATE_POST);
 
-$posts = forum_post_listing(
-    $topic['topic_id'],
-    $topicPagination->getOffset(),
-    $topicPagination->getRange(),
-    perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST)
-);
-
-if(!$posts) {
-    echo render_error(404);
-    return;
-}
-
-$canReply = !$topicIsArchived && !$topicIsLocked && !$topicIsDeleted && perms_check($perms, MSZ_FORUM_PERM_CREATE_POST);
-
-forum_topic_mark_read($topicUserId, $topic['topic_id'], $topic['forum_id']);
+$topicInfo->markRead($topicUser);
 
 Template::render('forum.topic', [
-    'topic_breadcrumbs' => forum_get_breadcrumbs($topic['forum_id']),
-    'global_accent_colour' => forum_get_colour($topic['forum_id']),
-    'topic_info' => $topic,
-    'topic_posts' => $posts,
+    'topic_perms' => $perms,
+    'topic_info' => $topicInfo,
     'can_reply' => $canReply,
     'topic_pagination' => $topicPagination,
     'topic_can_delete' => $canDelete,
+    'topic_can_view_deleted' => $canDeleteAny,
     'topic_can_nuke_or_restore' => $canNukeOrRestore,
     'topic_can_bump' => $canBumpTopic,
     'topic_can_lock' => $canLockTopic,
-    'topic_poll_options' => $pollOptions ?? [],
-    'topic_poll_user_answers' => $pollUserAnswers ?? [],
-    'topic_priority_votes' => $topicPriority ?? [],
 ]);
diff --git a/public/index.php b/public/index.php
index 96cf3d7c..1e42fd26 100644
--- a/public/index.php
+++ b/public/index.php
@@ -11,72 +11,106 @@ $request = HttpRequestMessage::fromGlobals();
 
 Router::setHandlerFormat('\Misuzu\Http\Handlers\%sHandler');
 Router::setFilterFormat('\Misuzu\Http\Filters\%sFilter');
-Router::addRoutes(
-    // Home
-    Route::get('/', 'index', 'Home'),
 
-    // Assets
-    Route::group('/assets', 'Assets')->addChildren(
-        Route::get('/([a-zA-Z0-9\-]+)\.(css|js)', 'serveComponent'),
-        Route::get('/avatar/([0-9]+)(?:\.png)?', 'serveAvatar'),
-        Route::get('/profile-background/([0-9]+)(?:\.png)?', 'serveProfileBackground'),
-    ),
+if(strpos($request->getUri()->getPath(), '.php') === false) {
+    Router::addRoutes(
+        // Home
+        Route::get('/', 'index', 'Home'),
 
-    // Info
-    Route::get('/info', 'index', 'Info'),
-    Route::get('/info/([A-Za-z0-9_/]+)', 'page', 'Info'),
+        // Assets
+        Route::group('/assets', 'Assets')->addChildren(
+            Route::get('/([a-zA-Z0-9\-]+)\.(css|js)', 'serveComponent'),
+            Route::get('/avatar/([0-9]+)(?:\.png)?', 'serveAvatar'),
+            Route::get('/profile-background/([0-9]+)(?:\.png)?', 'serveProfileBackground'),
+        ),
 
-    // Changelog
-    Route::get('/changelog', 'index', 'Changelog')->addChildren(
-        Route::get('.atom', 'feedAtom'),
-        Route::get('.rss', 'feedRss'),
-        Route::get('/change/([0-9]+)', 'change'),
-    ),
+        // Info
+        Route::get('/info', 'index', 'Info'),
+        Route::get('/info/([A-Za-z0-9_\-/]+)', 'page', 'Info'),
 
-    // News
-    Route::get('/news', 'index', 'News')->addChildren(
-        Route::get('.atom', 'feedIndexAtom'),
-        Route::get('.rss', 'feedIndexRss'),
-        Route::get('/([0-9]+)', 'viewCategory'),
-        Route::get('/([0-9]+).atom', 'feedCategoryAtom'),
-        Route::get('/([0-9]+).rss', 'feedCategoryRss'),
-        Route::get('/post/([0-9]+)', 'viewPost')
-    ),
+        // Changelog
+        Route::get('/changelog', 'index', 'Changelog')->addChildren(
+            Route::get('.atom', 'feedAtom'),
+            Route::get('.rss', 'feedRss'),
+            Route::get('/change/([0-9]+)', 'change'),
+        ),
 
-    // Forum
-    Route::group('/forum', 'Forum')->addChildren(
-        Route::get('/mark-as-read', 'markAsReadGET')->addFilters('EnforceLogIn'),
-        Route::post('/mark-as-read', 'markAsReadPOST')->addFilters('EnforceLogIn', 'ValidateCsrf'),
-    ),
+        // News
+        Route::get('/news', 'index', 'News')->addChildren(
+            Route::get('.atom', 'feedIndexAtom'),
+            Route::get('.rss', 'feedIndexRss'),
+            Route::get('/([0-9]+)', 'viewCategory'),
+            Route::get('/([0-9]+).atom', 'feedCategoryAtom'),
+            Route::get('/([0-9]+).rss', 'feedCategoryRss'),
+            Route::get('/post/([0-9]+)', 'viewPost')
+        ),
 
-    // Sock Chat
-    Route::create(['GET', 'POST'], '/_sockchat.php', 'phpFile', 'SockChat'),
-    Route::group('/_sockchat', 'SockChat')->addChildren(
-        Route::get('/emotes',  'emotes'),
-        Route::get('/bans',    'bans'),
-        Route::get('/login',   'login'),
-        Route::post('/bump',   'bump'),
-        Route::post('/verify', 'verify'),
-    ),
+        // Forum
+        Route::get('/forum', 'index', 'Forum.ForumIndex')->addChildren(
+            Route::get('/mark-as-read', 'markAsRead')->addFilters('EnforceLogIn'),
+            Route::post('/mark-as-read', 'markAsRead')->addFilters('EnforceLogIn', 'ValidateCsrf'),
 
-    // Redirects
-    Route::get('/index.php', url('index')),
-    Route::get('/info.php', url('info')),
-    Route::get('/settings.php', url('settings-index')),
-    Route::get('/changelog.php', 'legacy', 'Changelog'),
-    Route::get('/info.php/([A-Za-z0-9_/]+)', 'redir', 'Info'),
-    Route::get('/auth.php', 'legacy', 'Auth'),
-    Route::get('/news.php', 'legacy', 'News'),
-    Route::get('/news.php/rss', 'legacy', 'News'),
-    Route::get('/news.php/atom', 'legacy', 'News'),
-    Route::get('/news/index.php', 'legacy', 'News'),
-    Route::get('/news/category.php', 'legacy', 'News'),
-    Route::get('/news/post.php', 'legacy', 'News'),
-    Route::get('/news/feed.php', 'legacy', 'News'),
-    Route::get('/news/feed.php/rss', 'legacy', 'News'),
-    Route::get('/news/feed.php/atom', 'legacy', 'News'),
-    Route::get('/user-assets.php', 'serveLegacy', 'Assets'),
-);
+            Route::get('/([0-9]+)', 'category', 'Forum.ForumCategory')->addChildren(
+                Route::get('/create-topic', 'createView')->addFilters('EnforceLogIn'),
+                Route::post('/create-topic', 'createAction')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+            ),
+
+            Route::get('/topic/([0-9]+)', 'topic', 'Forum.ForumTopic')->addChildren(
+                Route::get('/live', 'live')->addFilters('EnforceLogIn'),
+                Route::post('/reply', 'reply')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/delete', 'delete')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/restore', 'restore')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/nuke', 'nuke')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/bump', 'bump')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/lock', 'lock')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/unlock', 'unlock')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+            ),
+
+            Route::get('/post/([0-9]+)', 'post', 'Forum.ForumPost')->addChildren(
+                Route::post('/', 'edit')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/delete', 'delete')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/restore', 'restore')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+                Route::post('/nuke', 'nuke')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+            ),
+
+            Route::post('/poll/([0-9]+)', 'vote', 'Forum.ForumPoll')->addFilters('EnforceLogIn', 'ValidateCsrf'),
+        ),
+
+        // Sock Chat
+        Route::create(['GET', 'POST'], '/_sockchat.php', 'phpFile', 'SockChat'),
+        Route::group('/_sockchat', 'SockChat')->addChildren(
+            Route::get('/emotes',  'emotes'),
+            Route::get('/bans',    'bans'),
+            Route::get('/login',   'login'),
+            Route::post('/bump',   'bump'),
+            Route::post('/verify', 'verify'),
+        ),
+    );
+} else {
+    Router::addRoutes(
+        // Redirects
+        Route::get('/index.php', url('index')),
+        Route::get('/info.php', url('info')),
+        Route::get('/settings.php', url('settings-index')),
+        Route::get('/changelog.php', 'legacy', 'Changelog'),
+        Route::get('/info.php/([A-Za-z0-9_\-/]+)', 'redir', 'Info'),
+        Route::get('/auth.php', 'legacy', 'Auth'),
+        Route::get('/news.php', 'legacy', 'News'),
+        Route::get('/news.php/rss', 'legacy', 'News'),
+        Route::get('/news.php/atom', 'legacy', 'News'),
+        Route::get('/news/index.php', 'legacy', 'News'),
+        Route::get('/news/category.php', 'legacy', 'News'),
+        Route::get('/news/post.php', 'legacy', 'News'),
+        Route::get('/news/feed.php', 'legacy', 'News'),
+        Route::get('/news/feed.php/rss', 'legacy', 'News'),
+        Route::get('/news/feed.php/atom', 'legacy', 'News'),
+        Route::get('/user-assets.php', 'serveLegacy', 'Assets'),
+        Route::create(['GET', 'POST'], '/forum/index.php', 'legacy', 'Forum.ForumIndex'),
+        Route::get('/forum/forum.php', 'legacy', 'Forum.ForumCategory'),
+        Route::get('/forum/topic.php', 'legacy', 'Forum.ForumTopic'),
+        Route::get('/forum/post.php', 'legacy', 'Forum.ForumPost'),
+    );
+}
 
 $response = Router::handle($request);
 $response->setHeader('X-Powered-By', 'Misuzu');
diff --git a/public/profile.php b/public/profile.php
index 149f3cc8..5fd9b4b5 100644
--- a/public/profile.php
+++ b/public/profile.php
@@ -344,50 +344,19 @@ switch($profileMode) {
 
     case 'forum-topics':
         $template = 'profile.topics';
-        $topicsCount = forum_topic_count_user($profileUser->getId(), $currentUserId);
-        $topicsPagination = new Pagination($topicsCount, 20);
-
-        if(!$topicsPagination->hasValidOffset()) {
-            echo render_error(404);
-            return;
-        }
-
-        $topics = forum_topic_listing_user(
-            $profileUser->getId(), $currentUserId,
-            $topicsPagination->getOffset(), $topicsPagination->getRange()
-        );
 
         Template::set([
             'title' => $profileUser->getUsername() . ' / topics',
             'canonical_url' => url('user-profile-forum-topics', ['user' => $profileUser->getId(), 'page' => Pagination::param()]),
-            'profile_topics' => $topics,
-            'profile_topics_pagination' => $topicsPagination,
         ]);
         break;
 
     case 'forum-posts':
         $template = 'profile.posts';
-        $postsCount = forum_post_count_user($profileUser->getId());
-        $postsPagination = new Pagination($postsCount, 20);
-
-        if(!$postsPagination->hasValidOffset()) {
-            echo render_error(404);
-            return;
-        }
-
-        $posts = forum_post_listing(
-            $profileUser->getId(),
-            $postsPagination->getOffset(),
-            $postsPagination->getRange(),
-            false,
-            true
-        );
 
         Template::set([
             'title' => $profileUser->getUsername() . ' / posts',
             'canonical_url' => url('user-profile-forum-posts', ['user' => $profileUser->getId(), 'page' => Pagination::param()]),
-            'profile_posts' => $posts,
-            'profile_posts_pagination' => $postsPagination,
         ]);
         break;
 
diff --git a/public/search.php b/public/search.php
index 3582f014..2f834003 100644
--- a/public/search.php
+++ b/public/search.php
@@ -1,6 +1,8 @@
 <?php
 namespace Misuzu;
 
+use Misuzu\Forum\ForumTopic;
+use Misuzu\Forum\ForumPost;
 use Misuzu\News\NewsPost;
 use Misuzu\Users\User;
 
@@ -9,8 +11,8 @@ require_once '../misuzu.php';
 $searchQuery = !empty($_GET['q']) && is_string($_GET['q']) ? $_GET['q'] : '';
 
 if(!empty($searchQuery)) {
-    $forumTopics = forum_topic_listing_search($searchQuery, User::hasCurrent() ? User::getCurrent()->getId() : 0);
-    $forumPosts = forum_post_search($searchQuery);
+    $forumTopics = ForumTopic::bySearchQuery($searchQuery);
+    $forumPosts = ForumPost::bySearchQuery($searchQuery);
     $newsPosts = NewsPost::bySearchQuery($searchQuery);
 
     $findUsers = DB::prepare(sprintf(
diff --git a/src/Colour.php b/src/Colour.php
index 832dbab2..fe012802 100644
--- a/src/Colour.php
+++ b/src/Colour.php
@@ -38,9 +38,10 @@ class Colour {
         return $this;
     }
 
-    public function getInherit(): bool {
+    public function isInherit(): bool {
         return ($this->getRaw() & self::FLAG_INHERIT) > 0;
     }
+    public function getInherit(): bool { return $this->isInherit(); }
     public function setInherit(bool $inherit): self {
         $raw = $this->getRaw();
 
diff --git a/src/Console/Commands/CronCommand.php b/src/Console/Commands/CronCommand.php
index b9299a5a..7e06463d 100644
--- a/src/Console/Commands/CronCommand.php
+++ b/src/Console/Commands/CronCommand.php
@@ -28,11 +28,19 @@ class CronCommand implements CommandInterface {
                     case 'func':
                         call_user_func($task['command']);
                         break;
+
+                    case 'selffunc':
+                        call_user_func(self::class . '::' . $task['command']);
+                        break;
                 }
             }
         }
     }
 
+    private static function syncForum(): void {
+        \Misuzu\Forum\ForumCategory::root()->synchronise(true);
+    }
+
     private const TASKS = [
         [
             'name' => 'Ensures main role exists.',
@@ -147,9 +155,9 @@ class CronCommand implements CommandInterface {
         ],
         [
             'name' => 'Recount forum topics and posts.',
-            'type' => 'func',
+            'type' => 'selffunc',
             'slow' => true,
-            'command' => 'forum_count_synchronise',
+            'command' => 'syncForum',
         ],
         [
             'name' => 'Clean up expired tfa tokens.',
diff --git a/src/Database/DatabaseStatement.php b/src/Database/DatabaseStatement.php
index 39c32e6e..93ea8a5a 100644
--- a/src/Database/DatabaseStatement.php
+++ b/src/Database/DatabaseStatement.php
@@ -43,13 +43,12 @@ class DatabaseStatement {
         return $out ? $out : $default;
     }
 
+    private $hasExecuted = false;
     public function fetchObject(string $className = 'stdClass', ?array $args = null, $default = null) {
-        $out = false;
-
-        if($this->isQuery || $this->execute()) {
-            $out = $args === null ? $this->stmt->fetchObject($className) : $this->stmt->fetchObject($className, $args);
-        }
+        if(!$this->hasExecuted)
+            $this->hasExecuted = $this->isQuery || $this->execute();
 
+        $out = $args === null ? $this->stmt->fetchObject($className) : $this->stmt->fetchObject($className, $args);
         return $out !== false ? $out : $default;
     }
 
diff --git a/src/Forum/ForumCategory.php b/src/Forum/ForumCategory.php
new file mode 100644
index 00000000..ff8fe6a8
--- /dev/null
+++ b/src/Forum/ForumCategory.php
@@ -0,0 +1,587 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\Colour;
+use Misuzu\DB;
+use Misuzu\Memoizer;
+use Misuzu\Pagination;
+use Misuzu\Users\User;
+
+class ForumCategoryException extends ForumException {}
+class ForumCategoryNotFoundException extends ForumCategoryException {}
+
+class ForumCategory {
+    public const TYPE_DISCUSSION = 0;
+    public const TYPE_CATEGORY = 1;
+    public const TYPE_LINK = 2;
+    public const TYPE_FEATURE = 3;
+
+    public const TYPES = [
+        self::TYPE_DISCUSSION, self::TYPE_CATEGORY, self::TYPE_LINK, self::TYPE_FEATURE,
+    ];
+    public const HAS_CHILDREN = [
+        self::TYPE_DISCUSSION, self::TYPE_CATEGORY, self::TYPE_FEATURE,
+    ];
+    public const HAS_TOPICS = [
+        self::TYPE_DISCUSSION, self::TYPE_FEATURE,
+    ];
+    public const HAS_PRIORITY_VOTES = [
+        self::TYPE_FEATURE,
+    ];
+
+    public const ROOT_ID = 0;
+
+    // Database fields
+    private $forum_id = -1;
+    private $forum_order = 0;
+    private $forum_parent = 0;
+    private $forum_name = '';
+    private $forum_type = self::TYPE_DISCUSSION;
+    private $forum_description = null;
+    private $forum_icon = null;
+    private $forum_colour = null;
+    private $forum_link = null;
+    private $forum_link_clicks = null;
+    private $forum_created = null;
+    private $forum_archived = 0;
+    private $forum_hidden = 0;
+    private $forum_count_topics = 0;
+    private $forum_count_posts = 0;
+
+    public const TABLE = 'forum_categories';
+    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
+    private const SELECT = '%1$s.`forum_id`, %1$s.`forum_order`, %1$s.`forum_parent`, %1$s.`forum_name`, %1$s.`forum_type`, %1$s.`forum_description`'
+                         . ', %1$s.`forum_icon`, %1$s.`forum_colour`, %1$s.`forum_link`, %1$s.`forum_link_clicks`'
+                         . ', %1$s.`forum_archived`, %1$s.`forum_hidden`, %1$s.`forum_count_topics`, %1$s.`forum_count_posts`'
+                         . ', UNIX_TIMESTAMP(%1$s.`forum_created`) AS `forum_created`';
+
+    private $categoryColour = null;
+    private $realColour = null;
+    private $parentCategory = null;
+    private $children = null;
+
+    public function getId(): int {
+        return $this->forum_id < 0 ? -1 : $this->forum_id;
+    }
+    public function isRoot(): bool {
+        return $this->forum_id === self::ROOT_ID;
+    }
+
+    public function getOrder(): int {
+        return $this->forum_order;
+    }
+    public function setOrder(int $order): self {
+        $this->forum_order = $order;
+        return $this;
+    }
+    public function moveBelow(self $other): self {
+        $this->setOrder($other->getOrder() + 1);
+        return $this;
+    }
+    public function moveAbove(self $other): self {
+        $this->setOrder($other->getOrder() - 1);
+        return $this;
+    }
+
+    public function getParentId(): int {
+        return $this->forum_parent < 0 ? -1 : $this->forum_parent;
+    }
+    public function setParentId(int $otherId): self {
+        $this->forum_parent = $otherId;
+        $this->parentCategory = null;
+        return $this;
+    }
+    public function hasParent(): bool {
+        return $this->getParentId() > 0;
+    }
+    public function getParent(): self {
+        if($this->parentCategory === null)
+            $this->parentCategory = $this->hasParent() ? self::byId($this->getParentId()) : self::root();
+        return $this->parentCategory;
+    }
+    public function setParent(?self $other): self {
+        $this->forum_parent = $other === null ? 0 : $other->getId();
+        $this->parentCategory = $other;
+        return $this;
+    }
+    public function getParentTree(): array {
+        $current = $this;
+        $parents = [];
+        while(!$current->isRoot())
+            $parents[] = $current = $current->getParent();
+        return array_reverse($parents);
+    }
+
+    public function getUrl(): string {
+        if($this->isRoot())
+            return url('forum-index');
+        return url('forum-category', ['forum' => $this->getId()]);
+    }
+
+    public function getName(): string {
+        return $this->forum_name;
+    }
+    public function setName(string $name): self {
+        $this->forum_name = $name;
+        return $this;
+    }
+
+    public function getType(): int {
+        return $this->forum_type;
+    }
+    public function setType(int $type): self {
+        $this->forum_type = $type;
+        return $this;
+    }
+    public function isDiscussionForum(): bool { return $this->getType() === self::TYPE_DISCUSSION; }
+    public function isCategoryForum(): bool   { return $this->getType() === self::TYPE_CATEGORY; }
+    public function isFeatureForum(): bool    { return $this->getType() === self::TYPE_FEATURE; }
+    public function isLink(): bool            { return $this->getType() === self::TYPE_LINK; }
+    public function canHaveChildren(): bool {
+        return in_array($this->getType(), self::HAS_CHILDREN);
+    }
+    public function canHaveTopics(): bool {
+        return in_array($this->getType(), self::HAS_TOPICS);
+    }
+    public function canHavePriorityVotes(): bool {
+        return in_array($this->getType(), self::HAS_PRIORITY_VOTES);
+    }
+
+    public function getDescription(): string {
+        return $this->forum_description ?? '';
+    }
+    public function hasDescription(): bool {
+        return !empty($this->forum_description);
+    }
+    public function setDescription(string $description): self {
+        $this->forum_description = empty($description) ? null : $description;
+        return $this;
+    }
+    public function getParsedDescription(): string {
+        return nl2br($this->getDescription());
+    }
+
+    public function getIcon(): string {
+        $icon = $this->getRealIcon();
+        if(!empty($icon))
+            return $icon;
+
+        if($this->isArchived())
+            return 'fas fa-archive fa-fw';
+
+        switch($this->getType()) {
+            case self::TYPE_FEATURE:
+                return 'fas fa-star fa-fw';
+            case self::TYPE_LINK:
+                return 'fas fa-link fa-fw';
+            case self::TYPE_CATEGORY:
+                return 'fas fa-folder fa-fw';
+        }
+
+        return 'fas fa-comments fa-fw';
+    }
+    public function getRealIcon(): string {
+        return $this->forum_icon ?? '';
+    }
+    public function hasIcon(): bool {
+        return !empty($this->forum_icon);
+    }
+    public function setIcon(string $icon): self {
+        $this->forum_icon = empty($icon) ? null : $icon;
+        return $this;
+    }
+
+    public function getColourRaw(): int {
+        return $this->forum_colour ?? 0x40000000;
+    }
+    public function setColourRaw(?int $raw): self {
+        $this->forum_colour = $raw;
+        $this->realColour = null;
+        $this->categoryColour = null;
+        return $this;
+    }
+    public function getColour(): Colour { // Swaps parent colour in if no category colour is present
+        if($this->realColour === null) {
+            $this->realColour = $this->getCategoryColour();
+            if($this->realColour->getInherit() && $this->hasParent())
+                $this->realColour = $this->getParent()->getColour();
+        }
+        return $this->realColour;
+    }
+    public function getCategoryColour(): Colour {
+        if($this->categoryColour === null)
+            $this->categoryColour = new Colour($this->getColourRaw());
+        return $this->categoryColour;
+    }
+    public function setColour(Colour $colour): self {
+        return $this->setColourRaw($colour === null ? null : $colour->getRaw());
+    }
+
+    public function getLink(): string {
+        return $this->forum_link ?? '';
+    }
+    public function hasLink(): bool {
+        return !empty($this->forum_link);
+    }
+    public function setLink(string $link): self {
+        $this->forum_link = empty($link) ? null : $link;
+    }
+
+    public function getLinkClicks(): int {
+        return $this->forum_link_clicks ?? -1;
+    }
+    public function shouldCountLinkClicks(): bool {
+        return $this->isLink() && $this->getLinkClicks() >= 0;
+    }
+    public function setCountLinkClicks(bool $state): self {
+        if($this->isLink() && $this->shouldCountLinkClicks() !== $state) {
+            $this->forum_link_clicks = $state ? 0 : null;
+
+            // forum_link_clicks is not affected by the save method so we must save
+            DB::prepare(
+                'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+                . ' SET `forum_link_clicks` = :clicks'
+                . ' WHERE `forum_id` = :category'
+            )   ->bind('category', $this->getId())
+                ->bind('clicks', $this->forum_link_clicks)
+                ->execute();
+        }
+        return $this;
+    }
+    public function increaseLinkClicks(): void {
+        if($this->shouldCountLinkClicks()) {
+            $this->forum_link_clicks = $this->getLinkClicks() + 1;
+            DB::prepare(
+                'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+                . ' SET `forum_link_clicks` = `forum_link_clicks` + 1'
+                . ' WHERE `forum_id` = :category AND `forum_type` = ' . self::TYPE_LINK
+            )->bind('category', $this->getId())->execute();
+        }
+    }
+
+    public function getCreatedTime(): int {
+        return $this->forum_created === null ? -1 : $this->forum_created;
+    }
+
+    public function isArchived(): bool {
+        return boolval($this->forum_archived);
+    }
+    public function setArchived(bool $archived): self {
+        $this->forum_archived = $archived ? 1 : 0;
+        return $this;
+    }
+
+    public function isHidden(): bool {
+        return boolval($this->forum_hidden);
+    }
+    public function setHidden(bool $hidden): self {
+        $this->forum_hidden = $hidden ? 1 : 0;
+        return $this;
+    }
+
+    public function getTopicCount(): int {
+        return $this->forum_count_topics ?? 0;
+    }
+    public function getPostCount(): int {
+        return $this->forum_count_posts ?? 0;
+    }
+    public function increaseTopicPostCount(bool $hasTopic): void {
+        if($this->isLink() || $this->isRoot())
+            return;
+        if($this->hasParent())
+            $this->getParent()->increaseTopicPostCount($hasTopic);
+
+        if($hasTopic)
+            $this->forum_count_topics = $this->getTopicCount() + 1;
+        $this->forum_count_posts = $this->getPostCount() + 1;
+
+        DB::prepare(
+            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+            . ' SET `forum_count_posts` = `forum_count_posts` + 1'
+            . ($hasTopic ? ', `forum_count_topics` = `forum_count_topics` + 1' : '')
+            . ' WHERE `forum_id` = :category'
+        )->bind('category', $this->getId())->execute();
+    }
+
+    // Param is fucking hackjob
+    // -1 = no check
+    // null = guest
+    // User = user
+    public function getChildren(/* ?User */ $viewer = -1): array {
+        if(!$this->canHaveChildren())
+            return [];
+        if($this->children === null)
+            $this->children = self::all($this);
+        if($viewer === null || $viewer instanceof User) {
+            $children = [];
+            foreach($this->children as $child)
+                if($child->canView($viewer))
+                    $children[] = $child;
+            return $children;
+        }
+        return $this->children;
+    }
+
+    public function getActualTopicCount(bool $includeDeleted = false): int {
+        if(!$this->canHaveTopics())
+            return 0;
+        return ForumTopic::countByCategory($this, $includeDeleted);
+    }
+    public function getActualPostCount(bool $includeDeleted = false): int {
+        if(!$this->canHaveTopics())
+            return 0;
+        return ForumPost::countByCategory($this, $includeDeleted);
+    }
+
+    public function getTopics(bool $includeDeleted = false, ?Pagination $pagination = null): array {
+        if(!$this->canHaveTopics())
+            return [];
+        return ForumTopic::byCategory($this, $includeDeleted, $pagination);
+    }
+
+    public function checkLegacyPermission(?User $user, int $perm, bool $strict = false): bool {
+        return forum_perms_check_user(
+            MSZ_FORUM_PERMS_GENERAL,
+            $this->isRoot() ? null : $this->getId(),
+            $user === null ? 0 : $user->getId(),
+            $perm, $strict
+        );
+    }
+    public function canView(?User $user): bool {
+        return $this->checkLegacyPermission($user, MSZ_FORUM_PERM_SET_READ);
+    }
+
+    public function hasRead(User $user): bool {
+        static $cache = [];
+
+        $cacheId = $user->getId() . ':' . $this->getId();
+        if(isset($cache[$cacheId]))
+            return $cache[$cacheId];
+
+        if(!$this->canView($user))
+            return $cache[$cacheId] = true;
+
+        $countUnread = (int)DB::prepare(
+            'SELECT COUNT(*) FROM `' . DB::PREFIX . ForumTopic::TABLE . '` AS ti'
+            . ' LEFT JOIN `' . DB::PREFIX . ForumTopicTrack::TABLE . '` AS tt'
+            . ' ON tt.`topic_id` = ti.`topic_id` AND tt.`user_id` = :user'
+            . ' WHERE ti.`forum_id` = :forum AND ti.`topic_deleted` IS NULL'
+            . ' AND ti.`topic_bumped` >= NOW() - INTERVAL :limit SECOND'
+            . ' AND (tt.`track_last_read` IS NULL OR tt.`track_last_read` < ti.`topic_bumped`)'
+        )->bind('forum', $this->getId())
+         ->bind('user', $user->getId())
+         ->bind('limit', ForumTopic::UNREAD_TIME_LIMIT)
+         ->fetchColumn();
+
+        if($countUnread > 0)
+            return $cache[$cacheId] = false;
+
+        foreach($this->getChildren() as $child)
+            if(!$child->hasRead($user))
+                return $cache[$cacheId] = false;
+
+        return $cache[$cacheId] = true;
+    }
+
+    public function markAsRead(User $user, bool $recursive = true): void {
+        if($this->isRoot()) {
+            if(!$recursive)
+                return;
+            $recursive = false;
+        }
+
+        if($recursive) {
+            $children = $this->getChildren($user);
+            foreach($children as $child)
+                $child->markAsRead($user, true);
+        }
+
+        $mark = DB::prepare(
+            'INSERT INTO `' . DB::PREFIX . ForumTopicTrack::TABLE . '`'
+            . ' (`user_id`, `topic_id`, `forum_id`, `track_last_read`)'
+            . ' SELECT u.`user_id`, t.`topic_id`, t.`forum_id`, NOW()'
+            . ' FROM `msz_forum_topics` AS t'
+            . ' LEFT JOIN `msz_users` AS u ON u.`user_id` = :user'
+            . ' WHERE t.`topic_deleted` IS NULL'
+            . ' AND t.`topic_bumped` >= NOW() - INTERVAL :limit SECOND'
+            . ($this->isRoot() ? '' : ' AND t.`forum_id` = :forum')
+            . ' GROUP BY t.`topic_id`'
+            . ' ON DUPLICATE KEY UPDATE `track_last_read` = NOW()'
+        )->bind('user', $user->getId())
+         ->bind('limit', ForumTopic::UNREAD_TIME_LIMIT);
+
+        if(!$this->isRoot())
+            $mark->bind('forum', $this->getId());
+
+        $mark->execute();
+    }
+
+    public function checkCooldown(User $user): int {
+        return (int)DB::prepare(
+            'SELECT TIMESTAMPDIFF(SECOND, COALESCE(MAX(`post_created`), NOW() - INTERVAL 1 YEAR), NOW())'
+            . ' FROM `' . DB::PREFIX . ForumPost::TABLE . '`'
+            . ' WHERE `forum_id` = :forum AND `user_id` = :user'
+        )->bind('forum', $this->getId())->bind('user', $user->getId())->fetchColumn();
+    }
+
+    public function getLatestTopic(?User $viewer = null): ?ForumTopic {
+        $lastTopic = ForumTopic::byCategoryLast($this);
+        $children = $this->getChildren($viewer);
+
+        foreach($children as $child) {
+            $topic = $child->getLatestTopic($viewer);
+            if($topic !== null && ($lastTopic === null || $topic->getBumpedTime() > $lastTopic->getBumpedTime()))
+                $lastTopic = $topic;
+        }
+
+        return $lastTopic;
+    }
+
+    // This function is really fucking expensive and should only be called by cron
+    // Optimise this as much as possible at some point
+    public function synchronise(bool $save = true): array {
+        $topics = 0; $posts = 0; $topicStats = [];
+
+        $children = $this->getChildren();
+        foreach($children as $child) {
+            $stats = $child->synchronise($save);
+            $topics += $stats['topics'];
+            $posts += $stats['posts'];
+            if(empty($topicStats) || (!empty($stats['topic_stats']) && $stats['topic_stats']['last_post_time'] > $topicStats['last_post_time']))
+                $topicStats = $stats['topic_stats'];
+        }
+
+        $getCounts = DB::prepare(
+            'SELECT :forum as `target_forum_id`, ('
+            .  ' SELECT COUNT(`topic_id`)'
+            .  ' FROM `msz_forum_topics`'
+            .  ' WHERE `forum_id` = `target_forum_id`'
+            .  ' AND `topic_deleted` IS NULL'
+            . ') AS `topics`, ('
+            .  ' SELECT COUNT(`post_id`)'
+            .  ' FROM `msz_forum_posts`'
+            .  ' WHERE `forum_id` = `target_forum_id`'
+            .  ' AND `post_deleted` IS NULL'
+            . ') AS `posts`'
+        );
+        $getCounts->bind('forum', $this->getId());
+        $counts = $getCounts->fetch();
+        $topics += $counts['topics'];
+        $posts += $counts['posts'];
+
+        foreach($this->getTopics() as $topic) {
+            $stats = $topic->synchronise($save);
+            if(empty($topicStats) || $stats['last_post_time'] > $topicStats['last_post_time'])
+                $topicStats = $stats;
+        }
+
+        if($save && !$this->isRoot()) {
+            $setCounts = DB::prepare(
+                'UPDATE `msz_forum_categories`'
+                . ' SET `forum_count_topics` = :topics, `forum_count_posts` = :posts'
+                . ' WHERE `forum_id` = :forum'
+            );
+            $setCounts->bind('forum', $this->getId());
+            $setCounts->bind('topics', $topics);
+            $setCounts->bind('posts', $posts);
+            $setCounts->execute();
+        }
+
+        return [
+            'topics' => $topics,
+            'posts' => $posts,
+            'topic_stats' => $topicStats,
+        ];
+    }
+
+    public static function root(): self {
+        static $root = null;
+        if($root === null) {
+            $root = new static;
+            $root->forum_id = self::ROOT_ID;
+            $root->forum_name = 'Forums';
+            $root->forum_type = self::TYPE_CATEGORY;
+            $root->forum_created = 1359324884;
+        }
+        return $root;
+    }
+
+    public function save(): void {
+        if($this->isRoot())
+            return;
+        $isInsert = $this->getId() < 0;
+
+        if($isInsert) {
+            $save = DB::prepare(
+                'INSERT INTO `' . DB::PREFIX . self::TABLE . '` ('
+                . '`forum_order`, `forum_parent`, `forum_name`, `forum_type`, `forum_description`, `forum_icon`'
+                . ', `forum_colour`, `forum_link`, `forum_archived`, `forum_hidden`'
+                . ') VALUES (:order, :parent, :name, :type, :desc, :icon, :colour, :link, :archived, :hidden)'
+            );
+        } else {
+            $save = DB::prepare(
+                'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+                . ' SET `forum_order` = :order, `forum_parent` = :parent, `forum_name` = :name, `forum_type` = :type'
+                . ', `forum_description` = :desc, `forum_icon` = :icon, `forum_colour` = :colour, `forum_link` = :link'
+                . ', `forum_archived` = :archived, `forum_hidden` = :hidden'
+                . ' WHERE `forum_id` = :category'
+            )->bind('category', $this->getId());
+        }
+
+        $save->bind('order', $this->forum_order)
+            ->bind('parent', $this->forum_parent)
+            ->bind('name', $this->forum_name)
+            ->bind('type', $this->forum_type)
+            ->bind('desc', $this->forum_description)
+            ->bind('icon', $this->forum_icon)
+            ->bind('colour', $this->forum_colour)
+            ->bind('link', $this->forum_link)
+            ->bind('archived', $this->forum_archived)
+            ->bind('hidden', $this->forum_hidden);
+
+        if($isInsert) {
+            $this->forum_id = $save->executeGetId();
+            $this->forum_created = time();
+        } else $save->execute();
+    }
+
+    private static function memoizer() {
+        static $memoizer = null;
+        if($memoizer === null)
+            $memoizer = new Memoizer;
+        return $memoizer;
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byId(int $categoryId): self {
+        if($categoryId === self::ROOT_ID)
+            return self::root();
+
+        return self::memoizer()->find($categoryId, function() use ($categoryId) {
+            $object = DB::prepare(self::byQueryBase() . ' WHERE `forum_id` = :category')
+                ->bind('category', $categoryId)
+                ->fetchObject(self::class);
+            if(!$object)
+                throw new ForumCategoryNotFoundException;
+            return $object;
+        });
+    }
+    public static function all(?self $parent = null): array {
+        $getObjects = DB::prepare(
+            self::byQueryBase()
+                . ($parent === null ? '' : ' WHERE `forum_parent` = :parent')
+                . ' ORDER BY `forum_order`'
+        );
+
+        if($parent !== null)
+            $getObjects->bind('parent', $parent->getId());
+
+        $objects = [];
+        $memoizer = self::memoizer();
+        while($object = $getObjects->fetchObject(self::class))
+            $memoizer->insert($objects[] = $object);
+        return $objects;
+    }
+}
diff --git a/src/Forum/ForumException.php b/src/Forum/ForumException.php
new file mode 100644
index 00000000..009adc0e
--- /dev/null
+++ b/src/Forum/ForumException.php
@@ -0,0 +1,6 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\MisuzuException;
+
+class ForumException extends MisuzuException {}
diff --git a/src/Forum/ForumLeaderboard.php b/src/Forum/ForumLeaderboard.php
new file mode 100644
index 00000000..93478c07
--- /dev/null
+++ b/src/Forum/ForumLeaderboard.php
@@ -0,0 +1,103 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\DB;
+
+final class ForumLeaderboard {
+    public const START_YEAR = 2018;
+    public const START_MONTH = 12;
+    public const CATEGORY_ALL = 0;
+
+    public static function isValidYear(?int $year): bool {
+        return !is_null($year) && $year >= self::START_YEAR && $year <= date('Y');
+    }
+
+    public static function isValidMonth(?int $year, ?int $month): bool {
+        if(is_null($month) || !self::isValidYear($year) || $month < 1 || $month > 12)
+            return false;
+
+        $combo = sprintf('%04d%02d', $year, $month);
+        $start = sprintf('%04d%02d', self::START_YEAR, self::START_MONTH);
+        $current = date('Ym');
+
+        return $combo >= $start && $combo <= $current;
+    }
+
+    public static function categories(): array {
+        $categories = [
+            self::CATEGORY_ALL => 'All Time',
+        ];
+
+        $currentYear = date('Y');
+        $currentMonth = date('m');
+
+        for($i = $currentYear; $i >= self::START_YEAR; $i--) {
+            $categories[$i] = sprintf('Leaderboard %d', $i);
+        }
+
+        for($i = $currentYear, $j = $currentMonth;;) {
+            $categories[sprintf('%d%02d', $i, $j)] = sprintf('Leaderboard %d-%02d', $i, $j);
+
+            if($j <= 1) {
+                $i--; $j = 12;
+            } else $j--;
+
+            if($i <= self::START_YEAR && $j < self::START_MONTH)
+                break;
+        }
+
+        return $categories;
+    }
+
+    public static function listing(
+        ?int $year = null,
+        ?int $month = null,
+        array $unrankedForums = [],
+        array $unrankedTopics = []
+    ): array {
+        $hasYear = self::isValidYear($year);
+        $hasMonth = $hasYear && self::isValidMonth($year, $month);
+        $unrankedForums = implode(',', $unrankedForums);
+        $unrankedTopics = implode(',', $unrankedTopics);
+
+        $rawLeaderboard = DB::query(sprintf(
+            '
+                SELECT
+                    u.`user_id`, u.`username`,
+                    COUNT(fp.`post_id`) as `posts`
+                FROM `msz_users` AS u
+                INNER JOIN `msz_forum_posts` AS fp
+                ON fp.`user_id` = u.`user_id`
+                WHERE fp.`post_deleted` IS NULL
+                %s %s %s
+                GROUP BY u.`user_id`
+                HAVING `posts` > 0
+                ORDER BY `posts` DESC
+            ',
+            $unrankedForums ? sprintf('AND fp.`forum_id` NOT IN (%s)', $unrankedForums) : '',
+            $unrankedTopics ? sprintf('AND fp.`topic_id` NOT IN (%s)', $unrankedTopics) : '',
+            !$hasYear ? '' : sprintf(
+                'AND DATE(fp.`post_created`) BETWEEN \'%1$04d-%2$02d-01\' AND \'%1$04d-%3$02d-31\'',
+                $year,
+                $hasMonth ? $month : 1,
+                $hasMonth ? $month : 12
+            )
+        ))->fetchAll();
+
+        $leaderboard = [];
+        $ranking = 0;
+        $lastPosts = null;
+
+        foreach($rawLeaderboard as $entry) {
+            if(is_null($lastPosts) || $lastPosts > $entry['posts']) {
+                $ranking++;
+                $lastPosts = $entry['posts'];
+            }
+
+            $entry['rank'] = $ranking;
+            $leaderboard[] = $entry;
+        }
+
+        return $leaderboard;
+    }
+}
diff --git a/src/Forum/ForumPoll.php b/src/Forum/ForumPoll.php
new file mode 100644
index 00000000..9c999aee
--- /dev/null
+++ b/src/Forum/ForumPoll.php
@@ -0,0 +1,166 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\DB;
+use Misuzu\Memoizer;
+use Misuzu\Users\User;
+
+class ForumPollException extends ForumException {}
+class ForumPollNotFoundException extends ForumPollException {}
+
+class ForumPoll {
+    // Database fields
+    private $poll_id = -1;
+    private $topic_id = null;
+    private $poll_max_votes = 0;
+    private $poll_expires = null;
+    private $poll_preview_results = 0;
+    private $poll_change_vote = 0;
+
+    public const TABLE = 'forum_polls';
+    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
+    private const SELECT = '%1$s.`poll_id`, %1$s.`topic_id`, %1$s.`poll_max_votes`, %1$s.`poll_preview_results`, %1$s.`poll_change_vote`'
+                         . ', UNIX_TIMESTAMP(%1$s.`poll_expires`) AS `poll_expires`';
+
+    private $topic = null;
+    private $options = null;
+    private $answers = [];
+    private $voteCount = -1;
+
+    public function getId(): int {
+        return $this->poll_id < 1 ? -1 : $this->poll_id;
+    }
+
+    public function getTopicId(): int {
+        return $this->topic_id < 1 ? -1 : $this->topic_id;
+    }
+    public function setTopicId(?int $topicId): self {
+        $this->topic_id = $topicId < 1 ? null : $topicId;
+        $this->topic = null;
+        return $this;
+    }
+    public function hasTopic(): bool {
+        return $this->getTopicId() > 0;
+    }
+    public function getTopic(): ForumTopic {
+        if($this->topic === null)
+            $this->topic = ForumTopic::byId($this->getTopicId());
+        return $this->topic;
+    }
+    public function setTopic(?ForumTopic $topic): self {
+        $this->topic_id = $topic === null ? null : $topic->getId();
+        $this->topic = $topic;
+        return $this;
+    }
+
+    public function getMaxVotes(): int {
+        return max(0, $this->poll_max_votes);
+    }
+    public function setMaxVotes(int $maxVotes): self {
+        $this->poll_max_votes = max(0, $maxVotes);
+        return $this;
+    }
+
+    public function getExpiresTime(): int {
+        return $this->poll_expires === null ? -1 : $this->poll_expires;
+    }
+    public function hasExpired(): bool {
+        return $this->getExpiresTime() >= time();
+    }
+    public function canExpire(): bool {
+        return $this->getExpiresTime() >= 0;
+    }
+    public function setExpiresTime(int $expires): self {
+        $this->poll_expires = $expires < 0 ? null : $expires;
+        return $this;
+    }
+
+    public function canPreviewResults(): bool {
+        return boolval($this->poll_preview_results);
+    }
+    public function setPreviewResults(bool $canPreview): self {
+        $this->poll_preview_results = $canPreview ? 1 : 0;
+        return $this;
+    }
+
+    public function canChangeVote(): bool {
+        return boolval($this->poll_change_vote);
+    }
+    public function setChangeVote(bool $canChange): self {
+        $this->poll_change_vote = $canChange ? 1 : 0;
+        return $this;
+    }
+
+    public function getVotes(): int {
+        if($this->voteCount < 0)
+            $this->voteCount = ForumPollAnswer::countByPoll($this);
+        return $this->voteCount;
+    }
+
+    public function getOptions(): array {
+        if($this->options === null)
+            $this->options = ForumPollOption::byPoll($this);
+        return $this->options;
+    }
+
+    public function getAnswers(?User $user): array {
+        if($user === null)
+            return [];
+        $userId = $user->getId();
+        if(!isset($this->answers[$userId]))
+            $this->answers[$userId] = ForumPollAnswer::byExact($user, $this);
+        return $this->answers[$userId];
+    }
+    public function hasVoted(?User $user): bool {
+        if($user === null)
+            return false;
+        $userId = $user->getId();
+        if(!isset($this->answers[$userId]))
+            return !empty($this->getAnswers($user));
+        return !empty($this->answers[$userId]);
+    }
+
+    public function canVoteOnPoll(?User $user): bool {
+        if($user === null)
+            return false;
+        if(!$this->hasTopic()) // introduce generic poll vote permission?
+            return true;
+        return forum_perms_check_user(
+            MSZ_FORUM_PERMS_GENERAL,
+            $this->getTopic()->getCategory()->getId(),
+            $user->getId(),
+            MSZ_FORUM_PERM_SET_READ
+        );
+    }
+
+    private static function memoizer() {
+        static $memoizer = null;
+        if($memoizer === null)
+            $memoizer = new Memoizer;
+        return $memoizer;
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byId(int $pollId): self {
+        return self::memoizer()->find($pollId, function() use ($pollId) {
+            $object = DB::prepare(self::byQueryBase() . ' WHERE `poll_id` = :poll')
+                ->bind('poll', $pollId)
+                ->fetchObject(self::class);
+            if(!$object)
+                throw new ForumPollNotFoundException;
+            return $object;
+        });
+    }
+    public static function byTopic(ForumTopic $topic): array {
+        $getObjects = DB::prepare(self::byQueryBase() . ' WHERE `topic_id` = :topic')
+            ->bind('topic', $topic->getId());
+
+        $objects = [];
+        $memoizer = self::memoizer();
+        while($object = $getObjects->fetchObject(self::class))
+            $memoizer->insert($objects[] = $object);
+        return $objects;
+    }
+}
diff --git a/src/Forum/ForumPollAnswer.php b/src/Forum/ForumPollAnswer.php
new file mode 100644
index 00000000..fd4c25b8
--- /dev/null
+++ b/src/Forum/ForumPollAnswer.php
@@ -0,0 +1,75 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\DB;
+use Misuzu\Users\User;
+
+class ForumPollAnswer {
+    // Database fields
+    private $user_id = -1;
+    private $poll_id = -1;
+    private $option_id = -1;
+
+    public const TABLE = 'forum_polls_answers';
+    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
+    private const SELECT = '%1$s.`user_id`, %1$s.`poll_id`, %1$s.`option_id`';
+
+    private $user = null;
+    private $poll = null;
+    private $option = null;
+
+    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->getUserId());
+        return $this->user;
+    }
+
+    public function getPollId(): int {
+        return $this->poll_id < 1 ? -1 : $this->poll_id;
+    }
+    public function getPoll(): ForumPoll {
+        if($this->poll === null)
+            $this->poll = ForumPoll::byId($this->getPollId());
+        return $this->poll;
+    }
+
+    public function getOptionId(): int {
+        return $this->option_id < 1 ? -1 : $this->option_id;
+    }
+    public function getOption(): ForumPollOption {
+        if($this->option === null)
+            $this->option = ForumPollOption::byId($this->getOptionId());
+        return $this->option;
+    }
+
+    public function hasAnswer(): bool {
+        return $this->getOptionId() > 0;
+    }
+
+    private static function countQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf('COUNT(*)', self::TABLE));
+    }
+    public static function countByPoll(ForumPoll $poll): int {
+        return (int)DB::prepare(
+            self::countQueryBase() . ' WHERE `poll_id` = :poll'
+        )->bind('poll', $poll->getId())->fetchColumn();
+    }
+    public static function countByOption(ForumPollOption $option): int {
+        return (int)DB::prepare(
+            self::countQueryBase() . ' WHERE `option_id` = :option'
+        )->bind('option', $option->getId())->fetchColumn();
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byExact(User $user, ForumPoll $poll): array {
+        return DB::prepare(self::byQueryBase() . ' WHERE `poll_id` = :poll AND `user_id` = :user')
+            ->bind('user', $user->getId())
+            ->bind('poll', $poll->getId())
+            ->fetchObjects(self::class);
+    }
+}
diff --git a/src/Forum/ForumPollOption.php b/src/Forum/ForumPollOption.php
new file mode 100644
index 00000000..39d24642
--- /dev/null
+++ b/src/Forum/ForumPollOption.php
@@ -0,0 +1,63 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\DB;
+use Misuzu\Users\User;
+
+class ForumPollOption {
+    // Database fields
+    private $option_id = -1;
+    private $poll_id = -1;
+    private $option_text = null;
+
+    public const TABLE = 'forum_polls_options';
+    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
+    private const SELECT = '%1$s.`option_id`, %1$s.`poll_id`, %1$s.`option_text`';
+
+    private $poll = null;
+    private $voteCount = -1;
+
+    public function getId(): int {
+        return $this->option_id < 1 ? -1 : $this->option_id;
+    }
+
+    public function getPollId(): int {
+        return $this->poll_id < 1 ? -1 : $this->poll_id;
+    }
+    public function getPoll(): ForumPoll {
+        if($this->poll === null)
+            $this->poll = ForumPoll::byId($this->getPollId());
+        return $this->poll;
+    }
+
+    public function getText(): string {
+        return $this->option_text ?? '';
+    }
+
+    public function getPercentage(): float {
+        return $this->getVotes() / $this->getPoll()->getVotes();
+    }
+
+    public function getVotes(): int {
+        if($this->voteCount < 0)
+            $this->voteCount = ForumPollAnswer::countByOption($this);
+        return $this->voteCount;
+    }
+
+    public function hasVotedFor(?User $user): bool {
+        if($user === null)
+            return false;
+        return array_find($this->getPoll()->getAnswers($user), function($answer) {
+            return $answer->getOptionId() === $this->getId();
+        }) !== null;
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byPoll(ForumPoll $poll): array {
+        return DB::prepare(self::byQueryBase() . ' WHERE `poll_id` = :poll')
+            ->bind('poll', $poll->getId())
+            ->fetchObjects(self::class);
+    }
+}
diff --git a/src/Forum/ForumPost.php b/src/Forum/ForumPost.php
new file mode 100644
index 00000000..86b9b480
--- /dev/null
+++ b/src/Forum/ForumPost.php
@@ -0,0 +1,488 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\DB;
+use Misuzu\Memoizer;
+use Misuzu\Pagination;
+use Misuzu\Parsers\Parser;
+use Misuzu\Users\User;
+use Misuzu\Users\UserNotFoundException;
+
+class ForumPostException extends ForumException {}
+class ForumPostNotFoundException extends ForumPostException {}
+class ForumPostCreationFailedException extends ForumPostException {}
+class ForumPostUpdateFailedException extends ForumPostException {}
+
+class ForumPost {
+    public const PER_PAGE = 10;
+
+    public const BODY_MIN_LENGTH = 1;
+    public const BODY_MAX_LENGTH = 60000;
+
+    public const EDIT_BUMP_THRESHOLD = 60 * 5;
+
+    public const DELETE_AGE_LIMIT = 60 * 60 * 24 * 7;
+
+    // Database fields
+    private $post_id = -1;
+    private $topic_id = -1;
+    private $forum_id = -1;
+    private $user_id = null;
+    private $post_ip = '::1';
+    private $post_text = '';
+    private $post_parse = Parser::BBCODE;
+    private $post_display_signature = 1;
+    private $post_created = null;
+    private $post_edited = null;
+    private $post_deleted = null;
+
+    public const TABLE = 'forum_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.`topic_id`, %1$s.`forum_id`, %1$s.`user_id`, %1$s.`post_text`, %1$s.`post_parse`, %1$s.`post_display_signature`'
+                         . ', INET6_NTOA(%1$s.`post_ip`) AS `post_ip`'
+                         . ', UNIX_TIMESTAMP(%1$s.`post_created`) AS `post_created`'
+                         . ', UNIX_TIMESTAMP(%1$s.`post_edited`) AS `post_edited`'
+                         . ', UNIX_TIMESTAMP(%1$s.`post_deleted`) AS `post_deleted`';
+
+    private $topic = null;
+    private $category = null;
+    private $user = null;
+    private $userLookedUp = false;
+
+    public function getId(): int {
+        return $this->post_id < 1 ? -1 : $this->post_id;
+    }
+
+    public function getTopicId(): int {
+        return $this->topic_id < 1 ? -1 : $this->topic_id;
+    }
+    public function setTopicId(int $topicId): self {
+        $this->topic_id = $topicId;
+        $this->topic = null;
+        return $this;
+    }
+    public function getTopic(): ForumTopic {
+        if($this->topic === null)
+            $this->topic = ForumTopic::byId($this->getTopicId());
+        return $this->topic;
+    }
+    public function setTopic(ForumTopic $topic): self {
+        $this->topic_id = $topic->getId();
+        $this->topic = $topic;
+        return $this;
+    }
+
+    public function getCategoryId(): int {
+        return $this->forum_id < 1 ? -1 : $this->forum_id;
+    }
+    public function setCategoryId(int $categoryId): self {
+        $this->forum_id = $categoryId;
+        $this->category = null;
+        return $this;
+    }
+    public function getCategory(): ForumCategory {
+        if($this->category === null)
+            $this->category = ForumCategory::byId($this->getCategoryId());
+        return $this->category;
+    }
+    public function setCategory(ForumCategory $category): self {
+        $this->forum_id = $category->getId();
+        $this->category = $category;
+        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;
+        $this->userLookedUp = false;
+        return $this;
+    }
+    public function hasUser(): bool {
+        return $this->getUserId() > 0;
+    }
+    public function getUser(): ?User {
+        if(!$this->userLookedUp) {
+            $this->userLookedUp = true;
+            try {
+                $this->user = User::byId($this->getUserId());
+            } catch(UserNotFoundException $ex) {}
+        }
+        return $this->user;
+    }
+    public function setUser(?User $user): self {
+        $this->user_id = $user === null ? null : $user->getId();
+        $this->user = $user;
+        $this->userLookedUp = true;
+        return $this;
+    }
+
+    public function getRemoteAddress(): string {
+        return $this->post_ip;
+    }
+    public function setRemoteAddress(string $remoteAddress): self {
+        $this->post_ip = $remoteAddress;
+        return $this;
+    }
+
+    public function getBody(): string {
+        return $this->post_text;
+    }
+    public function getParsedBody(): string {
+        return Parser::instance($this->getBodyParser())->parseText(htmlspecialchars($this->getBody()));
+    }
+    public function getFirstBodyParagraph(): string {
+        return htmlspecialchars(first_paragraph($this->getBody()));
+    }
+    public function setBody(string $body): self {
+        $this->post_text = empty($body) ? null : $body;
+        return $this;
+    }
+
+    public function getBodyParser(): int {
+        return $this->post_parse;
+    }
+    public function setBodyParser(int $parser): self {
+        $this->post_parse = $parser;
+        return $this;
+    }
+
+    public function getBodyClasses(): string {
+        if($this->getBodyParser() === Parser::MARKDOWN)
+            return 'markdown';
+        return '';
+    }
+
+    public function shouldDisplaySignature(): bool {
+        return boolval($this->post_display_signature);
+    }
+    public function setDisplaySignature(bool $display): self {
+        $this->post_display_signature = $display ? 1 : 0;
+        return $this;
+    }
+
+    public function getCreatedTime(): int {
+        return $this->post_created === null ? -1 : $this->post_created;
+    }
+    public function getAge(): int {
+        return time() - $this->getCreatedTime();
+    }
+    public function shouldBumpEdited(): bool {
+        return $this->getAge() > self::EDIT_BUMP_THRESHOLD;
+    }
+
+    public function getEditedTime(): int {
+        return $this->post_edited === null ? -1 : $this->post_edited;
+    }
+    public function isEdited(): bool {
+        return $this->getEditedTime() >= 0;
+    }
+    public function bumpEdited(): self {
+        $this->post_edited = time();
+        return $this;
+    }
+    public function stripEdited(): self {
+        $this->post_edited = null;
+        return $this;
+    }
+
+    public function getDeletedTime(): int {
+        return $this->post_deleted === null ? -1 : $this->post_deleted;
+    }
+    public function isDeleted(): bool {
+        return $this->getDeletedTime() >= 0;
+    }
+
+    public function isOpeningPost(): bool {
+        return $this->getTopic()->isOpeningPost($this);
+    }
+
+    public function isTopicAuthor(): bool {
+        return $this->getTopic()->isTopicAuthor($this->getUser());
+    }
+
+    public function getTopicOffset(bool $includeDeleted = false): int {
+        return (int)DB::prepare(
+            'SELECT COUNT(`post_id`) FROM `' . DB::PREFIX . self::TABLE . '`'
+            . ' WHERE `topic_id` = :topic AND `post_id` < :post'
+            . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL')
+        )->bind('topic', $this->getTopicId())->bind('post', $this->getId())->fetchColumn();
+    }
+    public function getTopicPage(bool $includeDeleted = false, int $postsPerPage = self::PER_PAGE): int {
+        return floor($this->getTopicOffset() / $postsPerPage) + 1;
+    }
+
+    public function canBeSeen(?User $user): bool {
+        if($user === null && $this->isDeleted())
+            return false;
+        // check if user can view deleted posts
+        return true;
+    }
+
+    // complete this implementation
+    public function canBeEdited(?User $user): bool {
+        if($user === null)
+            return false;
+        return $this->getUser()->getId() === $user->getId();
+    }
+
+    public static function validateBody(string $body): string {
+        $length = mb_strlen(trim($body));
+        if($length < self::BODY_MIN_LENGTH)
+            return 'short';
+        if($length > self::BODY_MAX_LENGTH)
+            return 'long';
+        return '';
+    }
+    public static function bodyValidationErrorString(string $error): string {
+        switch($error) {
+            case 'short':
+                return sprintf('Post body was too short, it has to be at least %d characters!', self::BODY_MIN_LENGTH);
+            case 'long':
+                return sprintf("Post body was too long, it can't be longer than %d characters!", self::BODY_MAX_LENGTH);
+            case '':
+                return 'Post body is correctly formatted!';
+            default:
+                return 'Post body is incorrectly formatted.';
+        }
+    }
+
+    public function canBeDeleted(User $user): string {
+        if(false) // check if viewable
+            return 'view';
+
+        if($this->isOpeningPost())
+            return 'opening';
+
+        // check if user can view deleted posts/is mod
+        $canDeleteAny = false;
+
+        if($this->isDeleted())
+            return $canDeleteAny ? 'deleted' : 'view';
+
+        if(!$canDeleteAny) {
+            if(false) // check if user can delete posts
+                return 'permission';
+            if($user->getId() !== $this->getUserId())
+                return 'owner';
+            if($this->getCreatedTime() <= time() - self::DELETE_AGE_LIMIT)
+                return 'age';
+        }
+
+        return '';
+    }
+    public static function canBeDeletedErrorString(string $error): string {
+        switch($error) {
+            case 'view':
+                return 'This post doesn\'t exist.';
+            case 'deleted':
+                return 'This post has already been marked as deleted.';
+            case 'permission':
+                return 'You aren\'t allowed to this post.';
+            case 'owner':
+                return 'You can only delete your own posts.';
+            case 'age':
+                return 'This post is too old to be deleted. Ask a moderator to remove it if you deem it absolutely necessary.';
+            case '':
+                return 'Post can be deleted!';
+            default:
+                return 'Post cannot be deleted.';
+        }
+    }
+
+    public function delete(): void {
+        if($this->isDeleted())
+            return;
+        $this->post_deleted = time();
+        DB::prepare('UPDATE `' . DB::PREFIX . self::TABLE . '` SET `post_deleted` = NOW() WHERE `post_id` = :post')
+            ->bind('post', $this->getId())
+            ->execute();
+    }
+    public function restore(): void {
+        if(!$this->isDeleted())
+            return;
+        $this->post_deleted = null;
+        DB::prepare('UPDATE `' . DB::PREFIX . self::TABLE . '` SET `post_deleted` = NULL WHERE `post_id` = :post')
+            ->bind('post', $this->getId())
+            ->execute();
+    }
+    public function nuke(): void {
+        if(!$this->isDeleted())
+            return;
+        DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `post_id` = :post')
+            ->bind('post', $this->getId())
+            ->execute();
+    }
+
+    public static function deleteTopic(ForumTopic $topic): void {
+        // Deleting posts should only be possible while the topic is already in a deleted state
+        if(!$topic->isDeleted())
+            return;
+        DB::prepare(
+            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+            . ' SET `post_deleted` = NOW()'
+            . ' WHERE `topic_id` = :topic'
+            . ' AND `post_deleted` IS NULL'
+        )->bind('topic', $topic->getId())->execute();
+    }
+    public static function restoreTopic(ForumTopic $topic): void {
+        // This looks like an error but it's not, run this before restoring the topic
+        if(!$topic->isDeleted())
+            return;
+        DB::prepare(
+            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+            . ' SET `post_deleted` = NULL'
+            . ' WHERE `topic_id` = :topic'
+            . ' AND `post_deleted` = FROM_UNIXTIME(:deleted)'
+        )->bind('topic', $topic->getId())->bind('deleted', $topic->getDeletedTime())->execute();
+    }
+    public static function nukeTopic(ForumTopic $topic): void { // Does this need to exist? Happens implicitly through foreign keys.
+        // Hard deleting should only be allowed if the topic is already soft deleted
+        if(!$topic->isDeleted())
+            return;
+        DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `topic_id` = :topic')
+            ->bind('topic', $topic->getId())
+            ->execute();
+    }
+
+    public static function create(
+        ForumTopic $topic,
+        User $user,
+        string $ipAddress,
+        string $text,
+        int $parser = Parser::PLAIN,
+        bool $displaySignature = true
+    ): ForumPost {
+        $create = DB::prepare(
+            'INSERT INTO `msz_forum_posts` ('
+            .  '`topic_id`, `forum_id`, `user_id`, `post_ip`, `post_text`, `post_parse`, `post_display_signature`'
+            . ') VALUES (:topic, :forum, :user, INET6_ATON(:ip), :body, :parser, :display_signature)'
+        )->bind('topic', $topic->getId())
+         ->bind('forum', $topic->getCategoryId())
+         ->bind('user', $user->getId())
+         ->bind('ip', $ipAddress)
+         ->bind('body', $text)
+         ->bind('parser', $parser)
+         ->bind('display_signature', $displaySignature ? 1 : 0)
+         ->execute();
+        if(!$create)
+            throw new ForumPostCreationFailedException;
+
+        $postId = DB::lastId();
+        if($postId < 1)
+            throw new ForumPostCreationFailedException;
+
+        try {
+            return self::byId($postId);
+        } catch(ForumPostNotFoundException $ex) {
+            throw new ForumPostCreationFailedException;
+        }
+    }
+
+    public function update(): void {
+        if($this->getId() < 1)
+            throw new ForumPostUpdateFailedException;
+
+        if(!DB::prepare(
+            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+            . ' SET `post_ip` = INET6_ATON(:ip),'
+            .     ' `post_text` = :body,'
+            .     ' `post_parse` = :parser,'
+            .     ' `post_display_signature` = :display_signature,'
+            .     ' `post_edited` = FROM_UNIXTIME(:edited)'
+            . ' WHERE `post_id` = :post'
+        )->bind('post', $this->getId())
+         ->bind('ip', $this->getRemoteAddress())
+         ->bind('body', $this->getBody())
+         ->bind('parser', $this->getBodyParser())
+         ->bind('display_signature', $this->shouldDisplaySignature() ? 1 : 0)
+         ->bind('edited', $this->isEdited() ? $this->getEditedTime() : null)
+         ->execute())
+            throw new ForumPostUpdateFailedException;
+    }
+
+    private static function countQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf('COUNT(*)', self::TABLE));
+    }
+    public static function countByCategory(ForumCategory $category, bool $includeDeleted = false): int {
+        return (int)DB::prepare(
+            self::countQueryBase()
+            . ' WHERE `forum_id` = :category'
+            . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL')
+        )->bind('category', $category->getId())->fetchColumn();
+    }
+    public static function countByTopic(ForumTopic $topic, bool $includeDeleted = false): int {
+        return (int)DB::prepare(
+            self::countQueryBase()
+            . ' WHERE `topic_id` = :topic'
+            . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL')
+        )->bind('topic', $topic->getId())->fetchColumn();
+    }
+
+    private static function memoizer() {
+        static $memoizer = null;
+        if($memoizer === null)
+            $memoizer = new Memoizer;
+        return $memoizer;
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byId(int $postId): self {
+        return self::memoizer()->find($postId, function() use ($postId) {
+            $object = DB::prepare(self::byQueryBase() . ' WHERE `post_id` = :post')
+                ->bind('post', $postId)
+                ->fetchObject(self::class);
+            if(!$object)
+                throw new ForumPostNotFoundException;
+            return $object;
+        });
+    }
+    public static function byTopic(ForumTopic $topic, bool $includeDeleted = false, ?Pagination $pagination = null): array {
+        $query = self::byQueryBase()
+                . ' WHERE `topic_id` = :topic'
+                . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL')
+                . ' ORDER BY `post_id`';
+
+        if($pagination !== null)
+            $query .= ' LIMIT :range OFFSET :offset';
+
+        $getObjects = DB::prepare($query)
+            ->bind('topic', $topic->getId());
+
+        if($pagination !== null)
+            $getObjects->bind('range', $pagination->getRange())
+                ->bind('offset', $pagination->getOffset());
+
+        $objects = [];
+        $memoizer = self::memoizer();
+        while($object = $getObjects->fetchObject(self::class))
+            $memoizer->insert($objects[] = $object);
+        return $objects;
+    }
+    public static function bySearchQuery(string $search, bool $includeDeleted = false, ?Pagination $pagination = null): array {
+        $query = self::byQueryBase()
+                . ' WHERE MATCH(`post_text`) AGAINST (:search IN NATURAL LANGUAGE MODE)'
+                . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL')
+                . ' ORDER BY `post_id`';
+
+        if($pagination !== null)
+            $query .= ' LIMIT :range OFFSET :offset';
+
+        $getObjects = DB::prepare($query)
+            ->bind('search', $search);
+
+        if($pagination !== null)
+            $getObjects->bind('range', $pagination->getRange())
+                ->bind('offset', $pagination->getOffset());
+
+        $objects = [];
+        $memoizer = self::memoizer();
+        while($object = $getObjects->fetchObject(self::class))
+            $memoizer->insert($objects[] = $object);
+        return $objects;
+    }
+}
diff --git a/src/Forum/ForumTopic.php b/src/Forum/ForumTopic.php
new file mode 100644
index 00000000..b38dd2b9
--- /dev/null
+++ b/src/Forum/ForumTopic.php
@@ -0,0 +1,588 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\DB;
+use Misuzu\Memoizer;
+use Misuzu\Pagination;
+use Misuzu\Users\User;
+
+class ForumTopicException extends ForumException {}
+class ForumTopicNotFoundException extends ForumTopicException {}
+class ForumTopicCreationFailedException extends ForumTopicException {}
+class ForumTopicUpdateFailedException extends ForumTopicException {}
+
+class ForumTopic {
+    public const TYPE_DISCUSSION = 0;
+    public const TYPE_STICKY = 1;
+    public const TYPE_ANNOUNCEMENT = 2;
+    public const TYPE_GLOBAL_ANNOUNCEMENT = 3;
+
+    public const TYPES = [
+        self::TYPE_DISCUSSION,
+        self::TYPE_STICKY,
+        self::TYPE_ANNOUNCEMENT,
+        self::TYPE_GLOBAL_ANNOUNCEMENT,
+    ];
+
+    public const TYPE_ORDER = [
+        self::TYPE_GLOBAL_ANNOUNCEMENT,
+        self::TYPE_ANNOUNCEMENT,
+        self::TYPE_STICKY,
+        self::TYPE_DISCUSSION,
+    ];
+
+    public const TYPE_IMPORTANT = [
+        self::TYPE_STICKY,
+        self::TYPE_ANNOUNCEMENT,
+        self::TYPE_GLOBAL_ANNOUNCEMENT,
+    ];
+
+    public const TITLE_MIN_LENGTH = 3;
+    public const TITLE_MAX_LENGTH = 100;
+
+    public const DELETE_AGE_LIMIT = 60 * 60 * 24;
+    public const DELETE_POST_LIMIT = 2;
+
+    public const UNREAD_TIME_LIMIT = 60 * 60 * 24 * 31;
+
+    // Database fields
+    private $topic_id = -1;
+    private $forum_id = -1;
+    private $user_id = null;
+    private $topic_type = self::TYPE_DISCUSSION;
+    private $topic_title = '';
+    private $topic_priority = 0;
+    private $topic_count_posts = 0;
+    private $topic_count_views = 0;
+    private $topic_post_first = null;
+    private $topic_post_last = null;
+    private $topic_created = null;
+    private $topic_bumped = null;
+    private $topic_deleted = null;
+    private $topic_locked = null;
+
+    public const TABLE = 'forum_topics';
+    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
+    private const SELECT = '%1$s.`topic_id`, %1$s.`forum_id`, %1$s.`user_id`, %1$s.`topic_type`, %1$s.`topic_title`'
+                         . ', %1$s.`topic_count_posts`, %1$s.`topic_count_views`, %1$s.`topic_post_first`, %1$s.`topic_post_last`'
+                         . ', UNIX_TIMESTAMP(%1$s.`topic_created`) AS `topic_created`'
+                         . ', UNIX_TIMESTAMP(%1$s.`topic_bumped`) AS `topic_bumped`'
+                         . ', UNIX_TIMESTAMP(%1$s.`topic_deleted`) AS `topic_deleted`'
+                         . ', UNIX_TIMESTAMP(%1$s.`topic_locked`) AS `topic_locked`';
+
+    private $category = null;
+    private $user = null;
+    private $firstPost = -1;
+    private $lastPost = -1;
+    private $priorityVotes = null;
+    private $polls = [];
+
+    public function getId(): int {
+        return $this->topic_id < 1 ? -1 : $this->topic_id;
+    }
+
+    public function getCategoryId(): int {
+        return $this->forum_id < 1 ? -1 : $this->forum_id;
+    }
+    public function setCategoryId(int $categoryId): self {
+        $this->forum_id = $categoryId;
+        $this->category = null;
+        return $this;
+    }
+    public function getCategory(): ForumCategory {
+        if($this->category === null)
+            $this->category = ForumCategory::byId($this->getCategoryId());
+        return $this->category;
+    }
+    public function setCategory(ForumCategory $category): self {
+        $this->forum_id = $category->getId();
+        $this->category = $category;
+        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->user === null && ($userId = $this->getUserId()) > 0)
+            $this->user = User::byId($userId);
+        return $this->user;
+    }
+    public function hasUser(): bool {
+        return $this->getUserId() > 0;
+    }
+    public function setUser(?User $user): self {
+        $this->user_id = $user === null ? null : $user->getId();
+        $this->user = $user;
+        return $this;
+    }
+
+    public function getType(): int {
+        return $this->topic_type;
+    }
+    public function setType(int $type): self {
+        $this->topic_type = $type;
+        return $this;
+    }
+    public function isNormal(): bool             { return $this->getType() === self::TYPE_DISCUSSION; }
+    public function isSticky(): bool             { return $this->getType() === self::TYPE_STICKY; }
+    public function isAnnouncement(): bool       { return $this->getType() === self::TYPE_ANNOUNCEMENT; }
+    public function isGlobalAnnouncement(): bool { return $this->getType() === self::TYPE_GLOBAL_ANNOUNCEMENT; }
+
+    public function isImportant(): bool {
+        return in_array($this->getType(), self::TYPE_IMPORTANT);
+    }
+
+    public function hasPriorityVoting(): bool {
+        return $this->getCategory()->canHavePriorityVotes();
+    }
+
+    public function getIcon(?User $viewer = null): string {
+        if($this->isDeleted())
+            return 'fas fa-trash-alt fa-fw';
+
+        if($this->isGlobalAnnouncement() || $this->isAnnouncement())
+            return 'fas fa-bullhorn fa-fw';
+        if($this->isSticky())
+            return 'fas fa-thumbtack fa-fw';
+
+        if($this->isLocked())
+            return 'fas fa-lock fa-fw';
+
+        if($this->hasPriorityVoting())
+            return 'far fa-star fa-fw';
+
+        return ($viewer === null || $this->hasRead($viewer) ? 'far' : 'fas') . ' fa-comment fa-fw';
+    }
+
+    public function getTitle(): string {
+        return $this->topic_title ?? '';
+    }
+    public function setTitle(string $title): self {
+        $this->topic_title = $title;
+        return $this;
+    }
+
+    public function getPriority(): int {
+        return $this->topic_priority < 1 ? 0 : $this->topic_priority;
+    }
+
+    public function getPostCount(): int {
+        return $this->topic_count_posts;
+    }
+    public function getPageCount(int $postsPerPage = 10): int {
+        return ceil($this->getPostCount() / $postsPerPage);
+    }
+
+    public function getViewCount(): int {
+        return $this->topic_count_views;
+    }
+    public function incrementViewCount(): void {
+        ++$this->topic_count_views;
+        DB::prepare('UPDATE `msz_forum_topics` SET `topic_count_views` = `topic_count_views` + 1 WHERE `topic_id` = :topic')
+            ->bind('topic', $this->getId())
+            ->execute();
+    }
+
+    public function getFirstPostId(): int {
+        return $this->topic_post_first < 1 ? -1 : $this->topic_post_first;
+    }
+    public function hasFirstPost(): bool {
+        return $this->getFirstPostId() > 0;
+    }
+    public function getFirstPost(): ?ForumPost {
+        if($this->firstPost === -1) {
+            if(!$this->hasFirstPost())
+                return null;
+            try {
+                $this->firstPost = ForumPost::byId($this->getFirstPostId());
+            } catch(ForumPostNotFoundException $ex) {
+                $this->firstPost = null;
+            }
+        }
+        return $this->firstPost;
+    }
+
+    public function getLastPostId(): int {
+        return $this->topic_post_last < 1 ? -1 : $this->topic_post_last;
+    }
+    public function hasLastPost(): bool {
+        return $this->getLastPostId() > 0;
+    }
+    public function getLastPost(): ?ForumPost {
+        if($this->lastPost === -1) {
+            if(!$this->hasLastPost())
+                return null;
+            try {
+                $this->lastPost = ForumPost::byId($this->getLastPostId());
+            } catch(ForumPostNotFoundException $ex) {
+                $this->lastPost = null;
+            }
+        }
+        return $this->lastPost;
+    }
+
+    public function getCreatedTime(): int {
+        return $this->topic_created === null ? -1 : $this->topic_created;
+    }
+
+    public function getBumpedTime(): int {
+        return $this->topic_bumped === null ? -1 : $this->topic_bumped;
+    }
+    public function bumpTopic(): void {
+        if($this->isDeleted())
+            return;
+        $this->topic_bumped = time();
+        DB::prepare(
+            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+            . ' SET `topic_bumped` = NOW()'
+            . ' WHERE `topic_id` = :topic'
+            . ' AND `topic_deleted` IS NULL'
+        )->bind('topic', $this->getId())->execute();
+    }
+
+    public function getDeletedTime(): int {
+        return $this->topic_deleted === null ? -1 : $this->topic_deleted;
+    }
+    public function isDeleted(): bool {
+        return $this->getDeletedTime() >= 0;
+    }
+
+    public function getLockedTime(): int {
+        return $this->topic_locked === null ? -1 : $this->topic_locked;
+    }
+    public function isLocked(): bool {
+        return $this->getLockedTime() >= 0;
+    }
+    public function setLocked(bool $locked): self {
+        if($this->isLocked() !== $locked)
+            $this->topic_locked = $locked ? time() : null;
+        return $this;
+    }
+
+    public function isArchived(): bool {
+        return $this->getCategory()->isArchived();
+    }
+
+    public function getActualPostCount(bool $includeDeleted = false): int {
+        return ForumPost::countByTopic($this, $includeDeleted);
+    }
+    public function getPosts(bool $includeDeleted = false, ?Pagination $pagination = null): array {
+        return ForumPost::byTopic($this, $includeDeleted, $pagination);
+    }
+
+    public function getPolls(): array {
+        if($this->polls === null)
+            $this->polls = ForumPoll::byTopic($this);
+        return $this->polls;
+    }
+
+    public function isAbandoned(): bool {
+        return $this->getBumpedTime() < time() - self::UNREAD_TIME_LIMIT;
+    }
+    public function hasRead(User $user): bool {
+        if($this->isAbandoned())
+            return true;
+
+        try {
+            $trackInfo = ForumTopicTrack::byTopicAndUser($this, $user);
+            return $trackInfo->getReadTime() >= $this->getBumpedTime();
+        } catch(ForumTopicTrackNotFoundException $ex) {
+            return false;
+        }
+    }
+    public function markRead(User $user): void {
+        if(!$this->hasRead($user))
+            $this->incrementViewCount();
+        ForumTopicTrack::bump($this, $user);
+    }
+
+    public function hasParticipated(?User $user): bool {
+        return $user !== null;
+    }
+
+    public function isOpeningPost(ForumPost $post): bool {
+        $firstPost = $this->getFirstPost();
+        return $firstPost !== null && $firstPost->getId() === $post->getId();
+    }
+    public function isTopicAuthor(?User $user): bool {
+        if($user === null)
+            return false;
+        return $user->getId() === $this->getUser()->getId();
+    }
+
+    public function getPriorityVotes(): array {
+        if($this->priorityVotes === null)
+            $this->priorityVotes = ForumTopicPriority::byTopic($this);
+        return $this->priorityVotes;
+    }
+
+    public function canVoteOnPriority(?User $user): bool {
+        if($user === null || !$this->hasPriorityVoting())
+            return false;
+        // shouldn't there be an actual permission for this?
+        return $this->getCategory()->canView($user);
+    }
+
+    public function canBeDeleted(User $user): string {
+        if(false) // check if viewable
+            return 'view';
+
+        // check if user can view deleted posts/is mod
+        $canDeleteAny = false;
+
+        if($this->isDeleted())
+            return $canDeleteAny ? 'deleted' : 'view';
+
+        if(!$canDeleteAny) {
+            if(false) // check if user can delete posts
+                return 'permission';
+            if($user->getId() !== $this->getUserId())
+                return 'owner';
+            if($this->getCreatedTime() <= time() - self::DELETE_AGE_LIMIT)
+                return 'age';
+            if($this->getActualPostCount(true) >= self::DELETE_POST_LIMIT)
+                return 'posts';
+        }
+
+        return '';
+    }
+    public static function canBeDeletedErrorString(string $error): string {
+        switch($error) {
+            case 'view':
+                return 'This topic doesn\'t exist.';
+            case 'deleted':
+                return 'This topic has already been marked as deleted.';
+            case 'permission':
+                return 'You aren\'t allowed to this topic.';
+            case 'owner':
+                return 'You can only delete your own topics.';
+            case 'age':
+                return 'This topic is too old to be deleted. Ask a moderator to remove it if you deem it absolutely necessary.';
+            case 'posts':
+                return 'This topic has too many replies to be deleted. Ask a moderator to remove it if you deem it absolutely necessary.';
+            case '':
+                return 'Topic can be deleted!';
+            default:
+                return 'Topic cannot be deleted.';
+        }
+    }
+
+    public function delete(): void {
+        if($this->isDeleted())
+            return;
+        $this->topic_deleted = time();
+        DB::prepare('UPDATE `' . DB::PREFIX . self::TABLE . '` SET `topic_deleted` = NOW() WHERE `topic_id` = :topic')
+            ->bind('topic', $this->getId())
+            ->execute();
+        ForumPost::deleteTopic($this);
+    }
+    public function restore(): void {
+        if(!$this->isDeleted())
+            return;
+        ForumPost::restoreTopic($this);
+        DB::prepare('UPDATE `' . DB::PREFIX . self::TABLE . '` SET `topic_deleted` = NULL WHERE `topic_id` = :topic')
+            ->bind('topic', $this->getId())
+            ->execute();
+        $this->topic_deleted = null;
+    }
+    public function nuke(): void {
+        if(!$this->isDeleted())
+            return;
+        DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `topic_id` = :topic')
+            ->bind('topic', $this->getId())
+            ->execute();
+        //ForumPost::nukeTopic($this);
+    }
+
+    public static function create(ForumCategory $category, User $user, string $title, int $type = self::TYPE_DISCUSSION): ForumTopic {
+        $create = DB::prepare(
+            'INSERT INTO `msz_forum_topics` (`forum_id`, `user_id`, `topic_title`, `topic_type`) VALUES (:forum, :user, :title, :type)'
+        )->bind('forum', $category->getId())->bind('user', $user->getId())
+         ->bind('title', $title)->bind('type', $type)
+         ->execute();
+        if(!$create)
+            throw new ForumTopicCreationFailedException;
+        $topicId = DB::lastId();
+        if($topicId < 1)
+            throw new ForumTopicCreationFailedException;
+
+        try {
+            return self::byId($topicId);
+        } catch(ForumTopicNotFoundException $ex) {
+            throw new ForumTopicCreationFailedException;
+        }
+    }
+
+    public function update(): void {
+        if($this->getId() < 1)
+            throw new ForumTopicUpdateFailedException;
+
+        if(!DB::prepare(
+            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+            . ' SET `topic_title` = :title,'
+            .     ' `topic_type` = :type'
+            . ' WHERE `topic_id` = :topic'
+        )->bind('topic', $this->getId())
+         ->bind('title', $this->getTitle())
+         ->bind('type', $this->getType())
+         ->execute())
+            throw new ForumTopicUpdateFailedException;
+    }
+
+    public function synchronise(bool $save = true): array {
+        $stats = DB::prepare(
+            'SELECT :topic AS `topic`, ('
+            .  'SELECT MIN(`post_id`) FROM `msz_forum_posts` WHERE `topic_id` = `topic`' // this shouldn't be deleteable without nuking the topic
+            . ') AS `first_post`, ('
+            .  'SELECT MAX(`post_id`) FROM `msz_forum_posts` WHERE `topic_id` = `topic` AND `post_deleted` IS NULL'
+            . ') AS `last_post`, ('
+            .  'SELECT COUNT(*) FROM `msz_forum_posts` WHERE `topic_id` = `topic` AND `post_deleted` IS NULL'
+            . ') AS `posts`, ('
+            .  'SELECT UNIX_TIMESTAMP(`post_created`) FROM `msz_forum_posts` WHERE `post_id` = `last_post`'
+            . ') AS `last_post_time`'
+        )->bind('topic', $this->getId())->fetch();
+
+        if($save) {
+            $this->topic_post_first = $stats['first_post'];
+            $this->topic_post_last = $stats['last_post'];
+            $this->topic_count_posts = $stats['posts'];
+            DB::prepare(
+                'UPDATE `msz_forum_topics`'
+                . ' SET `topic_post_first` = :first'
+                . ', `topic_post_last` = :last'
+                . ', `topic_count_posts` = :posts'
+                . ' WHERE `topic_id` = :topic'
+            )   ->bind('first', $this->topic_post_first)
+                ->bind('last', $this->topic_post_last)
+                ->bind('posts', $this->topic_count_posts)
+                ->bind('topic', $this->getId())
+                ->execute();
+        }
+
+        return $stats;
+    }
+
+    public static function validateTitle(string $title): string {
+        $length = mb_strlen(trim($title));
+        if($length < self::TITLE_MIN_LENGTH)
+            return 'short';
+        if($length > self::TITLE_MAX_LENGTH)
+            return 'long';
+        return '';
+    }
+    public static function titleValidationErrorString(string $error): string {
+        switch($error) {
+            case 'short':
+                return sprintf('Topic title was too short, it has to be at least %d characters!', self::TITLE_MIN_LENGTH);
+            case 'long':
+                return sprintf("Topic title was too long, it can't be longer than %d characters!", self::TITLE_MAX_LENGTH);
+            case '':
+                return 'Topic title is correctly formatted!';
+            default:
+                return 'Topic title is incorrectly formatted.';
+        }
+    }
+
+    private static function countQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf('COUNT(*)', self::TABLE));
+    }
+    public static function countByCategory(ForumCategory $category, bool $includeDeleted = false): int {
+        return (int)DB::prepare(
+            self::countQueryBase()
+            . ' WHERE `forum_id` = :category'
+            . ($includeDeleted ? '' : ' AND `topic_deleted` IS NULL')
+        )->bind('category', $category->getId())->fetchColumn();
+    }
+
+    private static function memoizer() {
+        static $memoizer = null;
+        if($memoizer === null)
+            $memoizer = new Memoizer;
+        return $memoizer;
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byId(int $topicId): self {
+        return self::memoizer()->find($topicId, function() use ($topicId) {
+            $object = DB::prepare(self::byQueryBase() . ' WHERE `topic_id` = :topic')
+                ->bind('topic', $topicId)
+                ->fetchObject(self::class);
+            if(!$object)
+                throw new ForumTopicNotFoundException;
+            return $object;
+        });
+    }
+    public static function byCategoryLast(ForumCategory $category): ?self {
+        return self::memoizer()->find(function($topic) use ($category) {
+            // This doesn't actually do what is advertised, but should be fine for the time being.
+            return $topic->getCategory()->getId() === $category->getId() && !$topic->isDeleted();
+        }, function() use ($category) {
+            return DB::prepare(
+                self::byQueryBase()
+                . ' WHERE `forum_id` = :category AND `topic_deleted` IS NULL'
+                . ' ORDER BY `topic_bumped` DESC'
+                . ' LIMIT 1'
+            )->bind('category', $category->getId())->fetchObject(self::class);
+        });
+    }
+    public static function byCategory(ForumCategory $category, bool $includeDeleted = false, ?Pagination $pagination = null): array {
+        if(!$category->canHaveTopics())
+            return [];
+
+        $query = self::byQueryBase()
+                . ' WHERE `forum_id` = :category'
+                . ($includeDeleted ? '' : ' AND `topic_deleted` IS NULL')
+                . ' ORDER BY FIELD(`topic_type`, ' . implode(',', self::TYPE_ORDER) . ')';
+
+        //if($category->canHavePriorityVotes())
+        //    $query .= ', `topic_priority` DESC';
+
+        $query .= ', `topic_bumped` DESC';
+
+        if($pagination !== null)
+            $query .= ' LIMIT :range OFFSET :offset';
+
+        $getObjects = DB::prepare($query)
+            ->bind('category', $category->getId());
+
+        if($pagination !== null)
+            $getObjects->bind('range', $pagination->getRange())
+                ->bind('offset', $pagination->getOffset());
+
+        $objects = [];
+        $memoizer = self::memoizer();
+        while($object = $getObjects->fetchObject(self::class))
+            $memoizer->insert($objects[] = $object);
+        return $objects;
+    }
+    public static function bySearchQuery(string $search, bool $includeDeleted = false, ?Pagination $pagination = null): array {
+        $query = self::byQueryBase()
+                . ' WHERE MATCH(`topic_title`) AGAINST (:search IN NATURAL LANGUAGE MODE)'
+                . ($includeDeleted ? '' : ' AND `topic_deleted` IS NULL')
+                . ' ORDER BY FIELD(`topic_type`, ' . implode(',', self::TYPE_ORDER) . '), `topic_bumped` DESC';
+
+        if($pagination !== null)
+            $query .= ' LIMIT :range OFFSET :offset';
+
+        $getObjects = DB::prepare($query)
+            ->bind('search', $search);
+
+        if($pagination !== null)
+            $getObjects->bind('range', $pagination->getRange())
+                ->bind('offset', $pagination->getOffset());
+
+        $objects = [];
+        $memoizer = self::memoizer();
+        while($object = $getObjects->fetchObject(self::class))
+            $memoizer->insert($objects[] = $object);
+        return $objects;
+    }
+}
diff --git a/src/Forum/ForumTopicPriority.php b/src/Forum/ForumTopicPriority.php
new file mode 100644
index 00000000..6d41a784
--- /dev/null
+++ b/src/Forum/ForumTopicPriority.php
@@ -0,0 +1,50 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\DB;
+use Misuzu\Users\User;
+
+class ForumTopicPriority {
+    // Database fields
+    private $topic_id = -1;
+    private $user_id = -1;
+    private $topic_priority = 0;
+
+    public const TABLE = 'forum_topics_priority';
+    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
+    private const SELECT = '%1$s.`topic_id`, %1$s.`user_id`, %1$s.`topic_priority`';
+
+    private $topic = null;
+    private $user = null;
+
+    public function getTopicId(): int {
+        return $this->topic_id < 1 ? -1 : $this->topic_id;
+    }
+    public function getTopic(): ForumTopic {
+        if($this->topic === null)
+            $this->topic = ForumTopic::byId($this->getTopicId());
+        return $this->topic;
+    }
+
+    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->getUserId());
+        return $this->user;
+    }
+
+    public function getPriority(): int {
+        return $this->topic_priority < 1 ? -1 : $this->topic_priority;
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byTopic(ForumTopic $topic): array {
+        return DB::prepare(self::byQueryBase() . ' WHERE `topic_id` = :topic')
+            ->bind('topic', $topic->getId())
+            ->fetchObjects(self::class);
+    }
+}
diff --git a/src/Forum/ForumTopicTrack.php b/src/Forum/ForumTopicTrack.php
new file mode 100644
index 00000000..045f3264
--- /dev/null
+++ b/src/Forum/ForumTopicTrack.php
@@ -0,0 +1,105 @@
+<?php
+namespace Misuzu\Forum;
+
+use Misuzu\DB;
+use Misuzu\Memoizer;
+use Misuzu\Users\User;
+
+class ForumTopicTrackException extends ForumException {}
+class ForumTopicTrackNotFoundException extends ForumTopicTrackException {}
+
+class ForumTopicTrack {
+    // Database fields
+    private $user_id = -1;
+    private $topic_id = -1;
+    private $forum_id = -1;
+    private $track_last_read = null;
+
+    public const TABLE = 'forum_topics_track';
+    private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
+    private const SELECT = '%1$s.`user_id`, %1$s.`topic_id`, %1$s.`forum_id`'
+                         . ', UNIX_TIMESTAMP(%1$s.`track_last_read`) AS `track_last_read`';
+
+    private $user = null;
+    private $topic = null;
+    private $category = null;
+
+    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->getUserId());
+        return $this->user;
+    }
+
+    public function getTopicId(): int {
+        return $this->topic_id < 1 ? -1 : $this->topic_id;
+    }
+    public function getTopic(): ForumTopic {
+        if($this->topic === null)
+            $this->topic = ForumTopic::byId($this->getTopicId());
+        return $this->topic;
+    }
+
+    public function getCategoryId(): int {
+        return $this->forum_id < 1 ? -1 : $this->forum_id;
+    }
+    public function getCategory(): ForumCategory {
+        if($this->category === null)
+            $this->category = ForumCategory::byId($this->getCategoryId());
+        return $this->category;
+    }
+
+    public function getReadTime(): int {
+        return $this->track_last_read === null ? -1 : $this->track_last_read;
+    }
+
+    public static function bump(ForumTopic $topic, User $user): void {
+        DB::prepare(
+            'REPLACE INTO `' . DB::PREFIX . self::TABLE . '`'
+            . ' (`user_id`, `topic_id`, `forum_id`, `track_last_read`)'
+            . ' VALUES (:user, :topic, :forum, NOW())'
+        )->bind('user', $user->getId())
+         ->bind('topic', $topic->getId())
+         ->bind('forum', $topic->getCategoryId())
+         ->execute();
+    }
+
+    private static function memoizer() {
+        static $memoizer = null;
+        if($memoizer === null)
+            $memoizer = new Memoizer;
+        return $memoizer;
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byTopicAndUser(ForumTopic $topic, User $user): ForumTopicTrack {
+        return self::memoizer()->find(function($track) use ($topic, $user) {
+            return $track->getTopicId() === $topic->getId() && $track->getUserId() === $user->getId();
+        }, function() use ($topic, $user) {
+            $obj = DB::prepare(self::byQueryBase() . ' WHERE `topic_id` = :topic AND `user_id` = :user')
+                ->bind('topic', $topic->getId())
+                ->bind('user', $user->getId())
+                ->fetchObject(self::class);
+            if(!$obj)
+                throw new ForumTopicTrackNotFoundException;
+            return $obj;
+        });
+    }
+    public static function byCategoryAndUser(ForumCategory $category, User $user): ForumTopicTrack {
+        return self::memoizer()->find(function($track) use ($category, $user) {
+            return $track->getCategoryId() === $category->getId() && $track->getUserId() === $user->getId();
+        }, function() use ($category, $user) {
+            $obj = DB::prepare(self::byQueryBase() . ' WHERE `forum_id` = :category AND `user_id` = :user')
+                ->bind('category', $category->getId())
+                ->bind('user', $user->getId())
+                ->fetchObject(self::class);
+            if(!$obj)
+                throw new ForumTopicTrackNotFoundException;
+            return $obj;
+        });
+    }
+}
diff --git a/src/Forum/ForumTrack.php b/src/Forum/ForumTrack.php
new file mode 100644
index 00000000..e69de29b
diff --git a/src/Forum/forum.php b/src/Forum/forum.php
deleted file mode 100644
index 04e5296d..00000000
--- a/src/Forum/forum.php
+++ /dev/null
@@ -1,537 +0,0 @@
-<?php
-define('MSZ_FORUM_TYPE_DISCUSSION', 0);
-define('MSZ_FORUM_TYPE_CATEGORY', 1);
-define('MSZ_FORUM_TYPE_LINK', 2);
-define('MSZ_FORUM_TYPE_FEATURE', 3);
-define('MSZ_FORUM_TYPES', [
-    MSZ_FORUM_TYPE_DISCUSSION,
-    MSZ_FORUM_TYPE_CATEGORY,
-    MSZ_FORUM_TYPE_LINK,
-    MSZ_FORUM_TYPE_FEATURE,
-]);
-
-define('MSZ_FORUM_MAY_HAVE_CHILDREN', [
-    MSZ_FORUM_TYPE_DISCUSSION,
-    MSZ_FORUM_TYPE_CATEGORY,
-    MSZ_FORUM_TYPE_FEATURE,
-]);
-
-define('MSZ_FORUM_MAY_HAVE_TOPICS', [
-    MSZ_FORUM_TYPE_DISCUSSION,
-    MSZ_FORUM_TYPE_FEATURE,
-]);
-
-define('MSZ_FORUM_HAS_PRIORITY_VOTING', [
-    MSZ_FORUM_TYPE_FEATURE,
-]);
-
-define('MSZ_FORUM_ROOT', 0);
-define('MSZ_FORUM_ROOT_DATA', [ // should be compatible with the data fetched in forum_get_root_categories
-    'forum_id' => MSZ_FORUM_ROOT,
-    'forum_name' => 'Forums',
-    'forum_children' => 0,
-    'forum_type' => MSZ_FORUM_TYPE_CATEGORY,
-    'forum_colour' => null,
-    'forum_permissions' => MSZ_FORUM_PERM_SET_READ,
-]);
-
-function forum_is_valid_type(int $type): bool {
-    return in_array($type, MSZ_FORUM_TYPES, true);
-}
-
-function forum_may_have_children(int $forumType): bool {
-    return in_array($forumType, MSZ_FORUM_MAY_HAVE_CHILDREN);
-}
-
-function forum_may_have_topics(int $forumType): bool {
-    return in_array($forumType, MSZ_FORUM_MAY_HAVE_TOPICS);
-}
-
-function forum_has_priority_voting(int $forumType): bool {
-    return in_array($forumType, MSZ_FORUM_HAS_PRIORITY_VOTING);
-}
-
-function forum_get(int $forumId, bool $showDeleted = false): array {
-    $getForum = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                `forum_id`, `forum_name`, `forum_type`, `forum_link`, `forum_archived`,
-                `forum_link_clicks`, `forum_parent`, `forum_colour`, `forum_icon`,
-                (
-                    SELECT COUNT(`topic_id`)
-                    FROM `msz_forum_topics`
-                    WHERE `forum_id` = f.`forum_id`
-                    %1$s
-                ) as `forum_topic_count`
-            FROM `msz_forum_categories` as f
-            WHERE `forum_id` = :forum_id
-        ',
-        $showDeleted ? '' : 'AND `topic_deleted` IS NULL'
-    ));
-    $getForum->bind('forum_id', $forumId);
-    return $getForum->fetch();
-}
-
-function forum_get_root_categories(int $userId): array {
-    $getCategories = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                f.`forum_id`, f.`forum_name`, f.`forum_type`, f.`forum_colour`, f.`forum_icon`,
-                (
-                    SELECT COUNT(`forum_id`)
-                    FROM `msz_forum_categories` AS sf
-                    WHERE sf.`forum_parent` = f.`forum_id`
-                ) AS `forum_children`
-            FROM `msz_forum_categories` AS f
-            WHERE f.`forum_parent` = 0
-            AND f.`forum_type` = %1$d
-            AND f.`forum_hidden` = 0
-            GROUP BY f.`forum_id`
-            ORDER BY f.`forum_order`
-        ',
-        MSZ_FORUM_TYPE_CATEGORY
-    ));
-    $categories = array_merge([MSZ_FORUM_ROOT_DATA], $getCategories->fetchAll());
-
-    $getRootForumCount = \Misuzu\DB::prepare(sprintf(
-        "
-            SELECT COUNT(`forum_id`)
-            FROM `msz_forum_categories`
-            WHERE `forum_parent` = %d
-            AND `forum_type` != %d
-        ",
-        MSZ_FORUM_ROOT,
-        MSZ_FORUM_TYPE_CATEGORY
-    ));
-    $categories[0]['forum_children'] = (int)$getRootForumCount->fetchColumn();
-
-    foreach($categories as $key => $category) {
-        $categories[$key]['forum_permissions'] = $perms = forum_perms_get_user($category['forum_id'], $userId)[MSZ_FORUM_PERMS_GENERAL];
-
-        if(!perms_check($perms, MSZ_FORUM_PERM_SET_READ)) {
-            unset($categories[$key]);
-            continue;
-        }
-
-        $categories[$key] = array_merge(
-            $category,
-            ['forum_unread' => forum_topics_unread($category['forum_id'], $userId)],
-            forum_latest_post($category['forum_id'], $userId)
-        );
-    }
-
-    return $categories;
-}
-
-function forum_get_breadcrumbs(
-    int $forumId,
-    string $linkFormat = '/forum/forum.php?f=%d',
-    string $rootFormat = '/forum/#f%d',
-    array $indexLink = ['Forums' => '/forum/']
-): array {
-    $breadcrumbs = [];
-    $getBreadcrumb = \Misuzu\DB::prepare('
-        SELECT `forum_id`, `forum_name`, `forum_type`, `forum_parent`
-        FROM `msz_forum_categories`
-        WHERE `forum_id` = :forum_id
-    ');
-
-    while($forumId > 0) {
-        $getBreadcrumb->bind('forum_id', $forumId);
-        $breadcrumb = $getBreadcrumb->fetch();
-
-        if(empty($breadcrumb)) {
-            break;
-        }
-
-        $breadcrumbs[$breadcrumb['forum_name']] = sprintf(
-            $breadcrumb['forum_parent'] === MSZ_FORUM_ROOT
-            && $breadcrumb['forum_type'] === MSZ_FORUM_TYPE_CATEGORY
-                ? $rootFormat
-                : $linkFormat,
-            $breadcrumb['forum_id']
-        );
-        $forumId = $breadcrumb['forum_parent'];
-    }
-
-    return array_reverse($breadcrumbs + $indexLink);
-}
-
-function forum_get_colour(int $forumId): int {
-    $getColours = \Misuzu\DB::prepare('
-        SELECT `forum_id`, `forum_parent`, `forum_colour`
-        FROM `msz_forum_categories`
-        WHERE `forum_id` = :forum_id
-    ');
-
-    while($forumId > 0) {
-        $getColours->bind('forum_id', $forumId);
-        $colourInfo = $getColours->fetch();
-
-        if(empty($colourInfo)) {
-            break;
-        }
-
-        if(!empty($colourInfo['forum_colour'])) {
-            return $colourInfo['forum_colour'];
-        }
-
-        $forumId = $colourInfo['forum_parent'];
-    }
-
-    return 0x40000000;
-}
-
-function forum_increment_clicks(int $forumId): void {
-    $incrementLinkClicks = \Misuzu\DB::prepare(sprintf('
-        UPDATE `msz_forum_categories`
-        SET `forum_link_clicks` = `forum_link_clicks` + 1
-        WHERE `forum_id` = :forum_id
-        AND `forum_type` = %d
-        AND `forum_link_clicks` IS NOT NULL
-    ', MSZ_FORUM_TYPE_LINK));
-    $incrementLinkClicks->bind('forum_id', $forumId);
-    $incrementLinkClicks->execute();
-}
-
-function forum_get_parent_id(int $forumId): int {
-    if($forumId < 1) {
-        return 0;
-    }
-
-    static $memoized = [];
-
-    if(array_key_exists($forumId, $memoized)) {
-        return $memoized[$forumId];
-    }
-
-    $getParent = \Misuzu\DB::prepare('
-        SELECT `forum_parent`
-        FROM `msz_forum_categories`
-        WHERE `forum_id` = :forum_id
-    ');
-    $getParent->bind('forum_id', $forumId);
-
-    return (int)$getParent->fetchColumn();
-}
-
-function forum_get_child_ids(int $forumId): array {
-    if($forumId < 1) {
-        return [];
-    }
-
-    static $memoized = [];
-
-    if(array_key_exists($forumId, $memoized)) {
-        return $memoized[$forumId];
-    }
-
-    $getChildren = \Misuzu\DB::prepare('
-        SELECT `forum_id`
-        FROM `msz_forum_categories`
-        WHERE `forum_parent` = :forum_id
-    ');
-    $getChildren->bind('forum_id', $forumId);
-    $children = $getChildren->fetchAll();
-
-    return $memoized[$forumId] = array_column($children, 'forum_id');
-}
-
-function forum_topics_unread(int $forumId, int $userId): int {
-    if($userId < 1 || $forumId < 1) {
-        return false;
-    }
-
-    static $memoized = [];
-    $memoId = "{$forumId}-{$userId}";
-
-    if(array_key_exists($memoId, $memoized)) {
-        return $memoized[$memoId];
-    }
-
-    $memoized[$memoId] = 0;
-    $children = forum_get_child_ids($forumId);
-
-    foreach($children as $child) {
-        $memoized[$memoId] += forum_topics_unread($child, $userId);
-    }
-
-    if(forum_perms_check_user(MSZ_FORUM_PERMS_GENERAL, $forumId, $userId, MSZ_FORUM_PERM_SET_READ)) {
-        $countUnread = \Misuzu\DB::prepare('
-            SELECT COUNT(ti.`topic_id`)
-            FROM `msz_forum_topics` AS ti
-            LEFT JOIN `msz_forum_topics_track` AS tt
-            ON tt.`topic_id` = ti.`topic_id` AND tt.`user_id` = :user_id
-            WHERE ti.`forum_id` = :forum_id
-            AND ti.`topic_deleted` IS NULL
-            AND ti.`topic_bumped` >= NOW() - INTERVAL 1 MONTH
-            AND (
-                tt.`track_last_read` IS NULL
-                OR tt.`track_last_read` < ti.`topic_bumped`
-            )
-        ');
-        $countUnread->bind('forum_id', $forumId);
-        $countUnread->bind('user_id', $userId);
-        $memoized[$memoId] += (int)$countUnread->fetchColumn();
-    }
-
-    return $memoized[$memoId];
-}
-
-function forum_latest_post(int $forumId, int $userId): array {
-    if($forumId < 1) {
-        return [];
-    }
-
-    static $memoized = [];
-    $memoId = "{$forumId}-{$userId}";
-
-    if(array_key_exists($memoId, $memoized)) {
-        return $memoized[$memoId];
-    }
-
-    if(!forum_perms_check_user(MSZ_FORUM_PERMS_GENERAL, $forumId, $userId, MSZ_FORUM_PERM_SET_READ)) {
-        return $memoized[$memoId] = [];
-    }
-
-    $getLastPost = \Misuzu\DB::prepare('
-        SELECT
-            p.`post_id` AS `recent_post_id`, t.`topic_id` AS `recent_topic_id`,
-            t.`topic_title` AS `recent_topic_title`, t.`topic_bumped` AS `recent_topic_bumped`,
-            p.`post_created` AS `recent_post_created`,
-            u.`user_id` AS `recent_post_user_id`,
-            u.`username` AS `recent_post_username`,
-            COALESCE(u.`user_colour`, r.`role_colour`) AS `recent_post_user_colour`,
-            UNIX_TIMESTAMP(p.`post_created`) AS `post_created_unix`
-        FROM `msz_forum_posts` AS p
-        LEFT JOIN `msz_forum_topics` AS t
-        ON t.`topic_id` = p.`topic_id`
-        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.`forum_id` = :forum_id
-        AND p.`post_deleted` IS NULL
-        ORDER BY p.`post_id` DESC
-    ');
-    $getLastPost->bind('forum_id', $forumId);
-    $currentLast = $getLastPost->fetch();
-
-    $children = forum_get_child_ids($forumId);
-
-    foreach($children as $child) {
-        $lastPost = forum_latest_post($child, $userId);
-
-        if(($currentLast['post_created_unix'] ?? 0) < ($lastPost['post_created_unix'] ?? 0)) {
-            $currentLast = $lastPost;
-        }
-    }
-
-    return $memoized[$memoId] = $currentLast;
-}
-
-function forum_get_children(int $parentId, int $userId): array {
-    $getListing = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                :user_id AS `target_user_id`,
-                f.`forum_id`, f.`forum_name`, f.`forum_description`, f.`forum_type`, f.`forum_icon`,
-                f.`forum_link`, f.`forum_link_clicks`, f.`forum_archived`, f.`forum_colour`,
-                f.`forum_count_topics`, f.`forum_count_posts`
-            FROM `msz_forum_categories` AS f
-            WHERE f.`forum_parent` = :parent_id
-            AND f.`forum_hidden` = 0
-            AND (
-                (f.`forum_parent` = %1$d AND f.`forum_type` != %2$d)
-                OR f.`forum_parent` != %1$d
-            )
-            GROUP BY f.`forum_id`
-            ORDER BY f.`forum_order`
-        ',
-        MSZ_FORUM_ROOT,
-        MSZ_FORUM_TYPE_CATEGORY
-    ));
-
-    $getListing->bind('user_id', $userId);
-    $getListing->bind('parent_id', $parentId);
-
-    $listing = $getListing->fetchAll();
-
-    foreach($listing as $key => $forum) {
-        $listing[$key]['forum_permissions'] = $perms = forum_perms_get_user($forum['forum_id'], $userId)[MSZ_FORUM_PERMS_GENERAL];
-
-        if(!perms_check($perms, MSZ_FORUM_PERM_SET_READ)) {
-            unset($listing[$key]);
-            continue;
-        }
-
-        $listing[$key] = array_merge(
-            $forum,
-            ['forum_unread' => forum_topics_unread($forum['forum_id'], $userId)],
-            forum_latest_post($forum['forum_id'], $userId)
-        );
-    }
-
-    return $listing;
-}
-
-function forum_timeout(int $forumId, int $userId): int {
-    $checkTimeout = \Misuzu\DB::prepare('
-        SELECT TIMESTAMPDIFF(SECOND, COALESCE(MAX(`post_created`), NOW() - INTERVAL 1 YEAR), NOW())
-        FROM `msz_forum_posts`
-        WHERE `forum_id` = :forum_id
-        AND `user_id` = :user_id
-    ');
-    $checkTimeout->bind('forum_id', $forumId);
-    $checkTimeout->bind('user_id', $userId);
-
-    return (int)$checkTimeout->fetchColumn();
-}
-
-// $forumId == null marks all forums as read
-function forum_mark_read(?int $forumId, int $userId): void {
-    if(($forumId !== null && $forumId < 1) || $userId < 1) {
-        return;
-    }
-
-    $entireForum = $forumId === null;
-
-    if(!$entireForum) {
-        $children = forum_get_child_ids($forumId);
-
-        foreach($children as $child) {
-            forum_mark_read($child, $userId);
-        }
-    }
-
-    $doMark = \Misuzu\DB::prepare(sprintf(
-        '
-            INSERT INTO `msz_forum_topics_track`
-                (`user_id`, `topic_id`, `forum_id`, `track_last_read`)
-            SELECT u.`user_id`, t.`topic_id`, t.`forum_id`, NOW()
-            FROM `msz_forum_topics` AS t
-            LEFT JOIN `msz_users` AS u
-            ON u.`user_id` = :user
-            WHERE t.`topic_deleted` IS NULL
-            AND t.`topic_bumped` >= NOW() - INTERVAL 1 MONTH
-            %1$s
-            GROUP BY t.`topic_id`
-            ON DUPLICATE KEY UPDATE
-                `track_last_read` = NOW()
-        ',
-        $entireForum ? '' : 'AND t.`forum_id` = :forum'
-    ));
-    $doMark->bind('user', $userId);
-
-    if(!$entireForum) {
-        $doMark->bind('forum', $forumId);
-    }
-
-    $doMark->execute();
-}
-
-function forum_posting_info(int $userId): array {
-    $getPostingInfo = \Misuzu\DB::prepare('
-        SELECT
-            u.`user_country`, u.`user_created`,
-            (
-                SELECT COUNT(`post_id`)
-                FROM `msz_forum_posts`
-                WHERE `user_id` = u.`user_id`
-                AND `post_deleted` IS NULL
-            ) AS `user_forum_posts`,
-            (
-                SELECT `post_parse`
-                FROM `msz_forum_posts`
-                WHERE `user_id` = u.`user_id`
-                AND `post_deleted` IS NULL
-                ORDER BY `post_id` DESC
-                LIMIT 1
-            ) AS `user_post_parse`
-        FROM `msz_users` as u
-        WHERE `user_id` = :user_id
-    ');
-    $getPostingInfo->bind('user_id', $userId);
-    return $getPostingInfo->fetch();
-}
-
-function forum_count_increase(int $forumId, bool $topic = false): void {
-    $increaseCount = \Misuzu\DB::prepare(sprintf(
-        '
-            UPDATE `msz_forum_categories`
-            SET `forum_count_posts` = `forum_count_posts` + 1
-                %s
-            WHERE `forum_id` = :forum
-        ',
-        $topic ? ',`forum_count_topics` = `forum_count_topics` + 1' : ''
-    ));
-    $increaseCount->bind('forum', $forumId);
-    $increaseCount->execute();
-}
-
-function forum_count_synchronise(int $forumId = MSZ_FORUM_ROOT, bool $save = true): array {
-    static $getChildren = null;
-    static $getCounts = null;
-    static $setCounts = null;
-
-    if(is_null($getChildren)) {
-        $getChildren = \Misuzu\DB::prepare('
-            SELECT `forum_id`, `forum_parent`
-            FROM `msz_forum_categories`
-            WHERE `forum_parent` = :parent
-        ');
-    }
-
-    if(is_null($getCounts)) {
-        $getCounts = \Misuzu\DB::prepare('
-            SELECT :forum as `target_forum_id`,
-            (
-                SELECT COUNT(`topic_id`)
-                FROM `msz_forum_topics`
-                WHERE `forum_id` = `target_forum_id`
-                AND `topic_deleted` IS NULL
-            ) AS `count_topics`,
-            (
-                SELECT COUNT(`post_id`)
-                FROM `msz_forum_posts`
-                WHERE `forum_id` = `target_forum_id`
-                AND `post_deleted` IS NULL
-            ) AS `count_posts`
-        ');
-    }
-
-    if($save && is_null($setCounts)) {
-        $setCounts = \Misuzu\DB::prepare('
-            UPDATE `msz_forum_categories`
-            SET `forum_count_topics` = :topics,
-                `forum_count_posts` = :posts
-            WHERE `forum_id` = :forum_id
-        ');
-    }
-
-    $getChildren->bind('parent', $forumId);
-    $children = $getChildren->fetchAll();
-
-    $topics = 0;
-    $posts = 0;
-
-    foreach($children as $child) {
-        $childCount = forum_count_synchronise($child['forum_id'], $save);
-        $topics += $childCount['topics'];
-        $posts += $childCount['posts'];
-    }
-
-    $getCounts->bind('forum', $forumId);
-    $counts = $getCounts->fetch();
-    $topics += $counts['count_topics'];
-    $posts += $counts['count_posts'];
-
-    if($forumId > 0 && $save) {
-        $setCounts->bind('forum_id', $forumId);
-        $setCounts->bind('topics', $topics);
-        $setCounts->bind('posts', $posts);
-        $setCounts->execute();
-    }
-
-    return compact('topics', 'posts');
-}
diff --git a/src/Forum/leaderboard.php b/src/Forum/leaderboard.php
deleted file mode 100644
index 7d528f50..00000000
--- a/src/Forum/leaderboard.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php
-define('MSZ_FORUM_LEADERBOARD_START_YEAR', 2018);
-define('MSZ_FORUM_LEADERBOARD_START_MONTH', 12);
-define('MSZ_FORUM_LEADERBOARD_CATEGORY_ALL', 0);
-
-function forum_leaderboard_year_valid(?int $year): bool {
-    return !is_null($year) && $year >= MSZ_FORUM_LEADERBOARD_START_YEAR && $year <= date('Y');
-}
-
-function forum_leaderboard_month_valid(?int $year, ?int $month): bool {
-    if(is_null($month) || !forum_leaderboard_year_valid($year) || $month < 1 || $month > 12) {
-        return false;
-    }
-
-    $combo = sprintf('%04d%02d', $year, $month);
-    $start = sprintf('%04d%02d', MSZ_FORUM_LEADERBOARD_START_YEAR, MSZ_FORUM_LEADERBOARD_START_MONTH);
-    $current = date('Ym');
-
-    return $combo >= $start && $combo <= $current;
-}
-
-function forum_leaderboard_categories(): array {
-    $categories = [
-        MSZ_FORUM_LEADERBOARD_CATEGORY_ALL => 'All Time',
-    ];
-
-    $currentYear = date('Y');
-    $currentMonth = date('m');
-
-    for($i = $currentYear; $i >= MSZ_FORUM_LEADERBOARD_START_YEAR; $i--) {
-        $categories[$i] = sprintf('Leaderboard %d', $i);
-    }
-
-    for($i = $currentYear, $j = $currentMonth;;) {
-        $categories[sprintf('%d%02d', $i, $j)] = sprintf('Leaderboard %d-%02d', $i, $j);
-
-        if($j <= 1) {
-            $i--; $j = 12;
-        } else $j--;
-
-        if($i <= MSZ_FORUM_LEADERBOARD_START_YEAR && $j < MSZ_FORUM_LEADERBOARD_START_MONTH)
-            break;
-    }
-
-    return $categories;
-}
-
-function forum_leaderboard_listing(
-    ?int $year = null,
-    ?int $month = null,
-    array $unrankedForums = [],
-    array $unrankedTopics = []
-): array {
-    $hasYear = forum_leaderboard_year_valid($year);
-    $hasMonth = $hasYear && forum_leaderboard_month_valid($year, $month);
-    $unrankedForums = implode(',', $unrankedForums);
-    $unrankedTopics = implode(',', $unrankedTopics);
-
-    $rawLeaderboard = \Misuzu\DB::query(sprintf(
-        '
-            SELECT
-                u.`user_id`, u.`username`,
-                COUNT(fp.`post_id`) as `posts`
-            FROM `msz_users` AS u
-            INNER JOIN `msz_forum_posts` AS fp
-            ON fp.`user_id` = u.`user_id`
-            WHERE fp.`post_deleted` IS NULL
-            %s %s %s
-            GROUP BY u.`user_id`
-            HAVING `posts` > 0
-            ORDER BY `posts` DESC
-        ',
-        $unrankedForums ? sprintf('AND fp.`forum_id` NOT IN (%s)', $unrankedForums) : '',
-        $unrankedTopics ? sprintf('AND fp.`topic_id` NOT IN (%s)', $unrankedTopics) : '',
-        !$hasYear ? '' : sprintf(
-            'AND DATE(fp.`post_created`) BETWEEN \'%1$04d-%2$02d-01\' AND \'%1$04d-%3$02d-31\'',
-            $year,
-            $hasMonth ? $month : 1,
-            $hasMonth ? $month : 12
-        )
-    ))->fetchAll();
-
-    $leaderboard = [];
-    $ranking = 0;
-    $lastPosts = null;
-
-    foreach($rawLeaderboard as $entry) {
-        if(is_null($lastPosts) || $lastPosts > $entry['posts']) {
-            $ranking++;
-            $lastPosts = $entry['posts'];
-        }
-
-        $entry['rank'] = $ranking;
-        $leaderboard[] = $entry;
-    }
-
-    return $leaderboard;
-}
diff --git a/src/Forum/perms.php b/src/Forum/perms.php
index c8ddd9cc..acb45ce4 100644
--- a/src/Forum/perms.php
+++ b/src/Forum/perms.php
@@ -43,6 +43,27 @@ define('MSZ_FORUM_PERM_MODES', [
     MSZ_FORUM_PERMS_GENERAL,
 ]);
 
+function forum_get_parent_id(int $forumId): int {
+    if($forumId < 1) {
+        return 0;
+    }
+
+    static $memoized = [];
+
+    if(array_key_exists($forumId, $memoized)) {
+        return $memoized[$forumId];
+    }
+
+    $getParent = \Misuzu\DB::prepare('
+        SELECT `forum_parent`
+        FROM `msz_forum_categories`
+        WHERE `forum_id` = :forum_id
+    ');
+    $getParent->bind('forum_id', $forumId);
+
+    return (int)$getParent->fetchColumn();
+}
+
 function forum_perms_get_user(?int $forum, int $user): array {
     $perms = perms_get_blank(MSZ_FORUM_PERM_MODES);
 
diff --git a/src/Forum/poll.php b/src/Forum/poll.php
deleted file mode 100644
index b2a56dc5..00000000
--- a/src/Forum/poll.php
+++ /dev/null
@@ -1,200 +0,0 @@
-<?php
-function forum_poll_get(int $poll): array {
-    if($poll < 1) {
-        return [];
-    }
-
-    $getPoll = \Misuzu\DB::prepare("
-        SELECT fp.`poll_id`, fp.`poll_max_votes`, fp.`poll_expires`, fp.`poll_preview_results`, fp.`poll_change_vote`,
-            (fp.`poll_expires` < CURRENT_TIMESTAMP) AS `poll_expired`,
-            (
-                SELECT COUNT(*)
-                FROM `msz_forum_polls_answers`
-                WHERE `poll_id` = fp.`poll_id`
-            ) AS `poll_votes`
-        FROM `msz_forum_polls` AS fp
-        WHERE fp.`poll_id` = :poll
-    ");
-    $getPoll->bind('poll', $poll);
-    return $getPoll->fetch();
-}
-
-function forum_poll_create(int $maxVotes = 1): int {
-    if($maxVotes < 1) {
-        return -1;
-    }
-
-    $createPoll = \Misuzu\DB::prepare("
-        INSERT INTO `msz_forum_polls`
-            (`poll_max_votes`)
-        VALUES
-            (:max_votes)
-    ");
-    $createPoll->bind('max_votes', $maxVotes);
-    return $createPoll->execute() ? \Misuzu\DB::lastId() : -1;
-}
-
-function forum_poll_get_options(int $poll): array {
-    if($poll < 1) {
-        return [];
-    }
-
-    static $polls = [];
-
-    if(array_key_exists($poll, $polls)) {
-        return $polls[$poll];
-    }
-
-    $getOptions = \Misuzu\DB::prepare('
-        SELECT `option_id`, `option_text`,
-            (
-                SELECT COUNT(*)
-                FROM `msz_forum_polls_answers`
-                WHERE `option_id` = fpo.`option_id`
-            ) AS `option_votes`
-        FROM `msz_forum_polls_options` AS fpo
-        WHERE `poll_id` = :poll
-    ');
-    $getOptions->bind('poll', $poll);
-
-    return $polls[$poll] = $getOptions->fetchAll();
-}
-
-function forum_poll_get_user_answers(int $poll, int $user): array {
-    if($poll < 1 || $user < 1) {
-        return [];
-    }
-
-    $getAnswers = \Misuzu\DB::prepare("
-        SELECT `option_id`
-        FROM `msz_forum_polls_answers`
-        WHERE `poll_id` = :poll
-        AND `user_id` = :user
-    ");
-    $getAnswers->bind('poll', $poll);
-    $getAnswers->bind('user', $user);
-    return array_column($getAnswers->fetchAll(), 'option_id');
-}
-
-function forum_poll_reset_answers(int $poll): void {
-    if($poll < 1) {
-        return;
-    }
-
-    $resetAnswers = \Misuzu\DB::prepare("
-        DELETE FROM `msz_forum_polls_answers`
-        WHERE `poll_id` = :poll
-    ");
-    $resetAnswers->bind('poll', $poll);
-    $resetAnswers->execute();
-}
-
-function forum_poll_option_add(int $poll, string $text): int {
-    if($poll < 1 || empty($text) || strlen($text) > 0xFF) {
-        return -1;
-    }
-
-    $addOption = \Misuzu\DB::prepare("
-        INSERT INTO `msz_forum_polls_options`
-            (`poll_id`, `option_text`)
-        VALUES
-            (:poll, :text)
-    ");
-    $addOption->bind('poll', $poll);
-    $addOption->bind('text', $text);
-    return $addOption->execute() ? \Misuzu\DB::lastId() : -1;
-}
-
-function forum_poll_option_remove(int $option): void {
-    if($option < 1) {
-        return;
-    }
-
-    $removeOption = \Misuzu\DB::prepare("
-        DELETE FROM `msz_forum_polls_options`
-        WHERE `option_id` = :option
-    ");
-    $removeOption->bind('option', $option);
-    $removeOption->execute();
-}
-
-function forum_poll_vote_remove(int $user, int $poll): void {
-    if($user < 1 || $poll < 1) {
-        return;
-    }
-
-    $purgeVote = \Misuzu\DB::prepare("
-        DELETE FROM `msz_forum_polls_answers`
-        WHERE `user_id` = :user
-        AND `poll_id` = :poll
-    ");
-    $purgeVote->bind('user', $user);
-    $purgeVote->bind('poll', $poll);
-    $purgeVote->execute();
-}
-
-function forum_poll_vote_cast(int $user, int $poll, int $option): void {
-    if($user < 1 || $poll < 1 || $option < 1) {
-        return;
-    }
-
-    $castVote = \Misuzu\DB::prepare("
-        INSERT INTO `msz_forum_polls_answers`
-            (`user_id`, `poll_id`, `option_id`)
-        VALUES
-            (:user, :poll, :option)
-    ");
-    $castVote->bind('user', $user);
-    $castVote->bind('poll', $poll);
-    $castVote->bind('option', $option);
-    $castVote->execute();
-}
-
-function forum_poll_validate_option(int $poll, int $option): bool {
-    if($poll < 1 || $option < 1) {
-        return false;
-    }
-
-    $checkVote = \Misuzu\DB::prepare("
-        SELECT COUNT(`option_id`) > 0
-        FROM `msz_forum_polls_options`
-        WHERE `poll_id` = :poll
-        AND `option_id` = :option
-    ");
-    $checkVote->bind('poll', $poll);
-    $checkVote->bind('option', $option);
-
-    return (bool)$checkVote->fetchColumn();
-}
-
-function forum_poll_has_voted(int $user, int $poll): bool {
-    if($user < 1 || $poll < 1) {
-        return false;
-    }
-
-    $getAnswers = \Misuzu\DB::prepare("
-        SELECT COUNT(`user_id`) > 0
-        FROM `msz_forum_polls_answers`
-        WHERE `poll_id` = :poll
-        AND `user_id` = :user
-    ");
-    $getAnswers->bind('poll', $poll);
-    $getAnswers->bind('user', $user);
-
-    return (bool)$getAnswers->fetchColumn();
-}
-
-function forum_poll_get_topic(int $poll): array {
-    if($poll < 1) {
-        return [];
-    }
-
-    $getTopic = \Misuzu\DB::prepare("
-        SELECT `forum_id`, `topic_id`, `topic_locked`
-        FROM `msz_forum_topics`
-        WHERE `poll_id` = :poll
-    ");
-    $getTopic->bind('poll', $poll);
-
-    return $getTopic->fetch();
-}
diff --git a/src/Forum/post.php b/src/Forum/post.php
deleted file mode 100644
index c569eb50..00000000
--- a/src/Forum/post.php
+++ /dev/null
@@ -1,360 +0,0 @@
-<?php
-define('MSZ_FORUM_POSTS_PER_PAGE', 10);
-
-function forum_post_create(
-    int $topicId,
-    int $forumId,
-    int $userId,
-    string $ipAddress,
-    string $text,
-    int $parser = \Misuzu\Parsers\Parser::PLAIN,
-    bool $displaySignature = true
-): int {
-    $createPost = \Misuzu\DB::prepare('
-        INSERT INTO `msz_forum_posts`
-            (`topic_id`, `forum_id`, `user_id`, `post_ip`, `post_text`, `post_parse`, `post_display_signature`)
-        VALUES
-            (:topic_id, :forum_id, :user_id, INET6_ATON(:post_ip), :post_text, :post_parse, :post_display_signature)
-    ');
-    $createPost->bind('topic_id', $topicId);
-    $createPost->bind('forum_id', $forumId);
-    $createPost->bind('user_id', $userId);
-    $createPost->bind('post_ip', $ipAddress);
-    $createPost->bind('post_text', $text);
-    $createPost->bind('post_parse', $parser);
-    $createPost->bind('post_display_signature', $displaySignature ? 1 : 0);
-
-    return $createPost->execute() ? \Misuzu\DB::lastId() : 0;
-}
-
-function forum_post_update(
-    int $postId,
-    string $ipAddress,
-    string $text,
-    int $parser = \Misuzu\Parsers\Parser::PLAIN,
-    bool $displaySignature = true,
-    bool $bumpUpdate = true
-): bool {
-    if($postId < 1) {
-        return false;
-    }
-
-    $updatePost = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_posts`
-        SET `post_ip` = INET6_ATON(:post_ip),
-            `post_text` = :post_text,
-            `post_parse` = :post_parse,
-            `post_display_signature` = :post_display_signature,
-            `post_edited` = IF(:bump, NOW(), `post_edited`)
-        WHERE `post_id` = :post_id
-    ');
-    $updatePost->bind('post_id', $postId);
-    $updatePost->bind('post_ip', $ipAddress);
-    $updatePost->bind('post_text', $text);
-    $updatePost->bind('post_parse', $parser);
-    $updatePost->bind('post_display_signature', $displaySignature ? 1 : 0);
-    $updatePost->bind('bump', $bumpUpdate ? 1 : 0);
-
-    return $updatePost->execute();
-}
-
-function forum_post_find(int $postId, int $userId): array {
-    $getPostInfo = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                p.`post_id`, p.`topic_id`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                    AND `post_id` < p.`post_id`
-                    AND `post_deleted` IS NULL
-                    ORDER BY `post_id`
-                ) as `preceeding_post_count`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                    AND `post_id` < p.`post_id`
-                    AND `post_deleted` IS NOT NULL
-                    ORDER BY `post_id`
-                ) as `preceeding_post_deleted_count`
-            FROM `msz_forum_posts` AS p
-            WHERE p.`post_id` = :post_id
-        '));
-    $getPostInfo->bind('post_id', $postId);
-    return $getPostInfo->fetch();
-}
-
-function forum_post_get(int $postId, bool $allowDeleted = false): array {
-    $getPost = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                p.`post_id`, p.`post_text`, p.`post_created`, p.`post_parse`, p.`post_display_signature`,
-                p.`topic_id`, p.`post_deleted`, p.`post_edited`, p.`topic_id`, p.`forum_id`,
-                INET6_NTOA(p.`post_ip`) AS `post_ip`,
-                u.`user_id` AS `poster_id`, u.`username` AS `poster_name`,
-                u.`user_created` AS `poster_joined`, u.`user_country` AS `poster_country`,
-                COALESCE(u.`user_colour`, r.`role_colour`) AS `poster_colour`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `user_id` = p.`user_id`
-                    AND `post_deleted` IS NULL
-                ) AS `poster_post_count`,
-                (
-                    SELECT MIN(`post_id`) = p.`post_id`
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                ) AS `is_opening_post`,
-                (
-                    SELECT `user_id` = u.`user_id`
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                    ORDER BY `post_id`
-                    LIMIT 1
-                ) AS `is_original_poster`
-            FROM `msz_forum_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 `post_id` = :post_id
-            %1$s
-            ORDER BY `post_id`
-        ',
-        $allowDeleted ? '' : 'AND `post_deleted` IS NULL'
-    ));
-    $getPost->bind('post_id', $postId);
-    return $getPost->fetch();
-}
-
-function forum_post_search(string $query): array {
-    $searchPosts = \Misuzu\DB::prepare('
-        SELECT
-            p.`post_id`, p.`post_text`, p.`post_created`, p.`post_parse`, p.`post_display_signature`,
-            p.`topic_id`, p.`post_deleted`, p.`post_edited`, p.`topic_id`, p.`forum_id`,
-            INET6_NTOA(p.`post_ip`) AS `post_ip`,
-            u.`user_id` AS `poster_id`, u.`username` AS `poster_name`,
-            u.`user_created` AS `poster_joined`, u.`user_country` AS `poster_country`,
-            u.`user_signature_content` AS `poster_signature_content`, u.`user_signature_parser` AS `poster_signature_parser`,
-            COALESCE(u.`user_colour`, r.`role_colour`) AS `poster_colour`,
-            COALESCE(u.`user_title`, r.`role_title`) AS `poster_title`,
-            (
-                SELECT COUNT(`post_id`)
-                FROM `msz_forum_posts`
-                WHERE `user_id` = p.`user_id`
-                AND `post_deleted` IS NULL
-            ) AS `poster_post_count`,
-            (
-                SELECT MIN(`post_id`) = p.`post_id`
-                FROM `msz_forum_posts`
-                WHERE `topic_id` = p.`topic_id`
-            ) AS `is_opening_post`,
-            (
-                SELECT `user_id` = u.`user_id`
-                FROM `msz_forum_posts`
-                WHERE `topic_id` = p.`topic_id`
-                ORDER BY `post_id`
-                LIMIT 1
-            ) AS `is_original_poster`
-        FROM `msz_forum_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 MATCH(p.`post_text`)
-        AGAINST (:query IN NATURAL LANGUAGE MODE)
-        AND `post_deleted` IS NULL
-        ORDER BY `post_id`
-    ');
-    $searchPosts->bind('query', $query);
-    return $searchPosts->fetchAll();
-}
-
-function forum_post_count_user(int $userId, bool $showDeleted = false): int {
-    $getPosts = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT COUNT(p.`post_id`)
-            FROM `msz_forum_posts` AS p
-            WHERE `user_id` = :user_id
-            %1$s
-        ',
-        $showDeleted ? '' : 'AND `post_deleted` IS NULL'
-    ));
-    $getPosts->bind('user_id', $userId);
-
-    return (int)$getPosts->fetchColumn();
-}
-
-function forum_post_listing(
-    int $topicId,
-    int $offset = 0,
-    int $take = 0,
-    bool $showDeleted = false,
-    bool $selectAuthor = false
-): array {
-    $hasPagination = $offset >= 0 && $take > 0;
-    $getPosts = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                p.`post_id`, p.`post_text`, p.`post_created`, p.`post_parse`,
-                p.`topic_id`, p.`post_deleted`, p.`post_edited`, p.`post_display_signature`,
-                INET6_NTOA(p.`post_ip`) AS `post_ip`,
-                u.`user_id` AS `poster_id`, u.`username` AS `poster_name`,
-                u.`user_created` AS `poster_joined`, u.`user_country` AS `poster_country`,
-                u.`user_signature_content` AS `poster_signature_content`, u.`user_signature_parser` AS `poster_signature_parser`,
-                COALESCE(u.`user_colour`, r.`role_colour`) AS `poster_colour`,
-                COALESCE(u.`user_title`, r.`role_title`) AS `poster_title`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `user_id` = p.`user_id`
-                    AND `post_deleted` IS NULL
-                ) AS `poster_post_count`,
-                (
-                    SELECT MIN(`post_id`) = p.`post_id`
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                ) AS `is_opening_post`,
-                (
-                    SELECT `user_id` = u.`user_id`
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                    ORDER BY `post_id`
-                    LIMIT 1
-                ) AS `is_original_poster`
-            FROM `msz_forum_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 %3$s = :topic_id
-            %1$s
-            ORDER BY `post_id`
-            %2$s
-        ',
-        $showDeleted ? '' : 'AND `post_deleted` IS NULL',
-        $hasPagination ? 'LIMIT :offset, :take' : '',
-        $selectAuthor ? 'p.`user_id`' : 'p.`topic_id`'
-    ));
-    $getPosts->bind('topic_id', $topicId);
-
-    if($hasPagination) {
-        $getPosts->bind('offset', $offset);
-        $getPosts->bind('take', $take);
-    }
-
-    return $getPosts->fetchAll();
-}
-
-define('MSZ_E_FORUM_POST_DELETE_OK', 0);        // deleting is fine
-define('MSZ_E_FORUM_POST_DELETE_USER', 1);      // invalid user
-define('MSZ_E_FORUM_POST_DELETE_POST', 2);      // post doesn't exist
-define('MSZ_E_FORUM_POST_DELETE_DELETED', 3);   // post is already marked as deleted
-define('MSZ_E_FORUM_POST_DELETE_OWNER', 4);     // you may only delete your own posts
-define('MSZ_E_FORUM_POST_DELETE_OLD', 5);       // posts has existed for too long to be deleted
-define('MSZ_E_FORUM_POST_DELETE_PERM', 6);      // you aren't allowed to delete posts
-define('MSZ_E_FORUM_POST_DELETE_OP', 7);        // this is the opening post of a topic
-
-// only allow posts made within a week of posting to be deleted by normal users
-define('MSZ_FORUM_POST_DELETE_LIMIT', 60 * 60 * 24 * 7);
-
-// set $userId to null for system request, make sure this is NEVER EVER null on user request
-// $postId can also be a the return value of forum_post_get if you already grabbed it once before
-function forum_post_can_delete($postId, ?int $userId = null): int {
-    if($userId !== null && $userId < 1) {
-        return MSZ_E_FORUM_POST_DELETE_USER;
-    }
-
-    if(is_array($postId)) {
-        $post = $postId;
-    } else {
-        $post = forum_post_get((int)$postId, true);
-    }
-
-    if(empty($post)) {
-        return MSZ_E_FORUM_POST_DELETE_POST;
-    }
-
-    $isSystemReq    = $userId === null;
-    $perms          = $isSystemReq ? 0      : forum_perms_get_user($post['forum_id'], $userId)[MSZ_FORUM_PERMS_GENERAL];
-    $canDeleteAny   = $isSystemReq ? true   : perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
-    $canViewPost    = $isSystemReq ? true   : perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM);
-    $postIsDeleted  = !empty($post['post_deleted']);
-
-    if(!$canViewPost) {
-        return MSZ_E_FORUM_POST_DELETE_POST;
-    }
-
-    if($post['is_opening_post']) {
-        return MSZ_E_FORUM_POST_DELETE_OP;
-    }
-
-    if($postIsDeleted) {
-        return $canDeleteAny ? MSZ_E_FORUM_POST_DELETE_DELETED : MSZ_E_FORUM_POST_DELETE_POST;
-    }
-
-    if($isSystemReq) {
-        return MSZ_E_FORUM_POST_DELETE_OK;
-    }
-
-    if(!$canDeleteAny) {
-        if(!perms_check($perms, MSZ_FORUM_PERM_DELETE_POST)) {
-            return MSZ_E_FORUM_POST_DELETE_PERM;
-        }
-
-        if($post['poster_id'] !== $userId) {
-            return MSZ_E_FORUM_POST_DELETE_OWNER;
-        }
-
-        if(strtotime($post['post_created']) <= time() - MSZ_FORUM_POST_DELETE_LIMIT) {
-            return MSZ_E_FORUM_POST_DELETE_OLD;
-        }
-    }
-
-    return MSZ_E_FORUM_POST_DELETE_OK;
-}
-
-function forum_post_delete(int $postId): bool {
-    if($postId < 1) {
-        return false;
-    }
-
-    $markDeleted = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_posts`
-        SET `post_deleted` = NOW()
-        WHERE `post_id` = :post
-        AND `post_deleted` IS NULL
-    ');
-    $markDeleted->bind('post', $postId);
-    return $markDeleted->execute();
-}
-
-function forum_post_restore(int $postId): bool {
-    if($postId < 1) {
-        return false;
-    }
-
-    $markDeleted = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_posts`
-        SET `post_deleted` = NULL
-        WHERE `post_id` = :post
-        AND `post_deleted` IS NOT NULL
-    ');
-    $markDeleted->bind('post', $postId);
-    return $markDeleted->execute();
-}
-
-function forum_post_nuke(int $postId): bool {
-    if($postId < 1) {
-        return false;
-    }
-
-    $markDeleted = \Misuzu\DB::prepare('
-        DELETE FROM `msz_forum_posts`
-        WHERE `post_id` = :post
-    ');
-    $markDeleted->bind('post', $postId);
-    return $markDeleted->execute();
-}
diff --git a/src/Forum/topic.php b/src/Forum/topic.php
deleted file mode 100644
index 6dd5c42d..00000000
--- a/src/Forum/topic.php
+++ /dev/null
@@ -1,709 +0,0 @@
-<?php
-define('MSZ_TOPIC_TYPE_DISCUSSION', 0);
-define('MSZ_TOPIC_TYPE_STICKY', 1);
-define('MSZ_TOPIC_TYPE_ANNOUNCEMENT', 2);
-define('MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT', 3);
-define('MSZ_TOPIC_TYPES', [
-    MSZ_TOPIC_TYPE_DISCUSSION,
-    MSZ_TOPIC_TYPE_STICKY,
-    MSZ_TOPIC_TYPE_ANNOUNCEMENT,
-    MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT,
-]);
-
-define('MSZ_TOPIC_TYPE_ORDER', [ // in which order to display topics, only add types here that should appear above others
-    MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT,
-    MSZ_TOPIC_TYPE_ANNOUNCEMENT,
-    MSZ_TOPIC_TYPE_STICKY,
-]);
-
-function forum_topic_is_valid_type(int $type): bool {
-    return in_array($type, MSZ_TOPIC_TYPES, true);
-}
-
-function forum_topic_create(
-    int $forumId,
-    int $userId,
-    string $title,
-    int $type = MSZ_TOPIC_TYPE_DISCUSSION
-): int {
-    if(empty($title) || !forum_topic_is_valid_type($type)) {
-        return 0;
-    }
-
-    $createTopic = \Misuzu\DB::prepare('
-        INSERT INTO `msz_forum_topics`
-            (`forum_id`, `user_id`, `topic_title`, `topic_type`)
-        VALUES
-            (:forum_id, :user_id, :topic_title, :topic_type)
-    ');
-    $createTopic->bind('forum_id', $forumId);
-    $createTopic->bind('user_id', $userId);
-    $createTopic->bind('topic_title', $title);
-    $createTopic->bind('topic_type', $type);
-
-    return $createTopic->execute() ? \Misuzu\DB::lastId() : 0;
-}
-
-function forum_topic_update(int $topicId, ?string $title, ?int $type = null): bool {
-    if($topicId < 1) {
-        return false;
-    }
-
-    // make sure it's null and not some other kinda empty
-    if(empty($title)) {
-        $title = null;
-    }
-
-    if($type !== null && !forum_topic_is_valid_type($type)) {
-        return false;
-    }
-
-    $updateTopic = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_topics`
-        SET `topic_title` = COALESCE(:topic_title, `topic_title`),
-            `topic_type` = COALESCE(:topic_type, `topic_type`)
-        WHERE `topic_id` = :topic_id
-    ');
-    $updateTopic->bind('topic_id', $topicId);
-    $updateTopic->bind('topic_title', $title);
-    $updateTopic->bind('topic_type', $type);
-
-    return $updateTopic->execute();
-}
-
-function forum_topic_get(int $topicId, bool $allowDeleted = false): array {
-    $getTopic = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                t.`topic_id`, t.`forum_id`, t.`topic_title`, t.`topic_type`, t.`topic_locked`, t.`topic_created`,
-                f.`forum_archived` AS `topic_archived`, t.`topic_deleted`, t.`topic_bumped`, f.`forum_type`,
-                tp.`poll_id`, tp.`poll_max_votes`, tp.`poll_expires`, tp.`poll_preview_results`, tp.`poll_change_vote`,
-                (tp.`poll_expires` < CURRENT_TIMESTAMP) AS `poll_expired`,
-                fp.`topic_id` AS `author_post_id`, fp.`user_id` AS `author_user_id`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    AND `post_deleted` IS NULL
-                ) AS `topic_count_posts`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    AND `post_deleted` IS NOT NULL
-                ) AS `topic_count_posts_deleted`,
-                (
-                    SELECT COUNT(*)
-                    FROM `msz_forum_polls_answers`
-                    WHERE `poll_id` = tp.`poll_id`
-                ) AS `poll_votes`
-            FROM `msz_forum_topics` AS t
-            LEFT JOIN `msz_forum_categories` AS f
-            ON f.`forum_id` = t.`forum_id`
-            LEFT JOIN `msz_forum_posts` AS fp
-            ON fp.`post_id` = (
-                SELECT MIN(`post_id`)
-                FROM `msz_forum_posts`
-                WHERE `topic_id` = t.`topic_id`
-            )
-            LEFT JOIN `msz_forum_polls` AS tp
-            ON tp.`poll_id` = t.`poll_id`
-            WHERE t.`topic_id` = :topic_id
-            %s
-        ',
-        $allowDeleted ? '' : 'AND t.`topic_deleted` IS NULL'
-    ));
-    $getTopic->bind('topic_id', $topicId);
-    return $getTopic->fetch();
-}
-
-function forum_topic_bump(int $topicId): bool {
-    $bumpTopic = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_topics`
-        SET `topic_bumped` = NOW()
-        WHERE `topic_id` = :topic_id
-        AND `topic_deleted` IS NULL
-    ');
-    $bumpTopic->bind('topic_id', $topicId);
-    return $bumpTopic->execute();
-}
-
-function forum_topic_views_increment(int $topicId): void {
-    if($topicId < 1) {
-        return;
-    }
-
-    $bumpViews = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_topics`
-        SET `topic_count_views` = `topic_count_views` + 1
-        WHERE `topic_id` = :topic_id
-    ');
-    $bumpViews->bind('topic_id', $topicId);
-    $bumpViews->execute();
-}
-
-function forum_topic_mark_read(int $userId, int $topicId, int $forumId): void {
-    if($userId < 1) {
-        return;
-    }
-
-    // previously a TRIGGER was used to achieve this behaviour,
-    // but those explode when running on a lot of queries (like forum_mark_read() does)
-    // so instead we get to live with this garbage now
-    try {
-        $markAsRead = \Misuzu\DB::prepare('
-            INSERT INTO `msz_forum_topics_track`
-                (`user_id`, `topic_id`, `forum_id`, `track_last_read`)
-            VALUES
-                (:user_id, :topic_id, :forum_id, NOW())
-        ');
-        $markAsRead->bind('user_id', $userId);
-        $markAsRead->bind('topic_id', $topicId);
-        $markAsRead->bind('forum_id', $forumId);
-
-        if($markAsRead->execute()) {
-            forum_topic_views_increment($topicId);
-        }
-    } catch(PDOException $ex) {
-        if($ex->getCode() != '23000') {
-            throw $ex;
-        }
-
-        $markAsRead = \Misuzu\DB::prepare('
-            UPDATE `msz_forum_topics_track`
-            SET `track_last_read` = NOW(),
-                `forum_id` = :forum_id
-            WHERE `user_id` = :user_id
-            AND `topic_id` = :topic_id
-        ');
-        $markAsRead->bind('user_id', $userId);
-        $markAsRead->bind('topic_id', $topicId);
-        $markAsRead->bind('forum_id', $forumId);
-        $markAsRead->execute();
-    }
-}
-
-function forum_topic_listing(
-    int $forumId,               int $userId,
-    int $offset = 0,            int $take = 0,
-    bool $showDeleted = false,  bool $sortByPriority = false
-): array {
-    $hasPagination = $offset >= 0 && $take > 0;
-    $getTopics = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                :user_id AS `target_user_id`,
-                t.`topic_id`, t.`topic_title`, t.`topic_locked`, t.`topic_type`, t.`topic_created`,
-                t.`topic_bumped`, t.`topic_deleted`, t.`topic_count_views`, f.`forum_type`,
-                COALESCE(SUM(tp.`topic_priority`), 0) AS `topic_priority`,
-                au.`user_id` AS `author_id`, au.`username` AS `author_name`,
-                COALESCE(au.`user_colour`, ar.`role_colour`) AS `author_colour`,
-                lp.`post_id` AS `response_id`,
-                lp.`post_created` AS `response_created`,
-                lu.`user_id` AS `respondent_id`,
-                lu.`username` AS `respondent_name`,
-                COALESCE(lu.`user_colour`, lr.`role_colour`) AS `respondent_colour`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    %5$s
-                ) AS `topic_count_posts`,
-                (
-                    SELECT CEIL(COUNT(`post_id`) / %6$d)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    %5$s
-                ) AS `topic_pages`,
-                (
-                    SELECT
-                        `target_user_id` > 0
-                    AND
-                        t.`topic_bumped` > NOW() - INTERVAL 1 MONTH
-                    AND (
-                        SELECT COUNT(ti.`topic_id`) < 1
-                        FROM `msz_forum_topics_track` AS tt
-                        RIGHT JOIN `msz_forum_topics` AS ti
-                        ON ti.`topic_id` = tt.`topic_id`
-                        WHERE ti.`topic_id` = t.`topic_id`
-                        AND tt.`user_id` = `target_user_id`
-                        AND `track_last_read` >= `topic_bumped`
-                    )
-                ) AS `topic_unread`,
-                (
-                    SELECT COUNT(`post_id`) > 0
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    AND `user_id` = `target_user_id`
-                    LIMIT 1
-                ) AS `topic_participated`
-            FROM `msz_forum_topics` AS t
-            LEFT JOIN `msz_forum_topics_priority` AS tp
-            ON tp.`topic_id` = t.`topic_id`
-            LEFT JOIN `msz_forum_categories` AS f
-            ON f.`forum_id` = t.`forum_id`
-            LEFT JOIN `msz_users` AS au
-            ON t.`user_id` = au.`user_id`
-            LEFT JOIN `msz_roles` AS ar
-            ON ar.`role_id` = au.`display_role`
-            LEFT JOIN `msz_forum_posts` AS lp
-            ON lp.`post_id` = (
-                SELECT `post_id`
-                FROM `msz_forum_posts`
-                WHERE `topic_id` = t.`topic_id`
-                %5$s
-                ORDER BY `post_id` DESC
-                LIMIT 1
-            )
-            LEFT JOIN `msz_users` AS lu
-            ON lu.`user_id` = lp.`user_id`
-            LEFT JOIN `msz_roles` AS lr
-            ON lr.`role_id` = lu.`display_role`
-            WHERE (
-                t.`forum_id` = :forum_id
-                OR t.`topic_type` = %3$d
-            )
-            %1$s
-            GROUP BY t.`topic_id`
-            ORDER BY FIELD(t.`topic_type`, %4$s) DESC, %7$s t.`topic_bumped` DESC
-            %2$s
-        ',
-        $showDeleted ? '' : 'AND t.`topic_deleted` IS NULL',
-        $hasPagination ? 'LIMIT :offset, :take' : '',
-        MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT,
-        implode(',', array_reverse(MSZ_TOPIC_TYPE_ORDER)),
-        $showDeleted ? '' : 'AND `post_deleted` IS NULL',
-        MSZ_FORUM_POSTS_PER_PAGE,
-        $sortByPriority ? '`topic_priority` DESC,' : ''
-    ));
-    $getTopics->bind('forum_id', $forumId);
-    $getTopics->bind('user_id', $userId);
-
-    if($hasPagination) {
-        $getTopics->bind('offset', $offset);
-        $getTopics->bind('take', $take);
-    }
-
-    return $getTopics->fetchAll();
-}
-
-function forum_topic_count_user(int $authorId, int $userId, bool $showDeleted = false): int {
-    $getTopics = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT COUNT(`topic_id`)
-            FROM `msz_forum_topics` AS t
-            WHERE t.`user_id` = :author_id
-            %1$s
-        ',
-        $showDeleted ? '' : 'AND t.`topic_deleted` IS NULL'
-    ));
-    $getTopics->bind('author_id', $authorId);
-    //$getTopics->bind('user_id', $userId);
-
-    return (int)$getTopics->fetchColumn();
-}
-
-// Remove unneccesary stuff from the sql stmt
-function forum_topic_listing_user(
-    int $authorId,
-    int $userId,
-    int $offset = 0,
-    int $take = 0,
-    bool $showDeleted = false
-): array {
-    $hasPagination = $offset >= 0 && $take > 0;
-    $getTopics = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                :user_id AS `target_user_id`,
-                t.`topic_id`, t.`topic_title`, t.`topic_locked`, t.`topic_type`, t.`topic_created`,
-                t.`topic_bumped`, t.`topic_deleted`, t.`topic_count_views`, f.`forum_type`,
-                au.`user_id` AS `author_id`, au.`username` AS `author_name`,
-                COALESCE(au.`user_colour`, ar.`role_colour`) AS `author_colour`,
-                lp.`post_id` AS `response_id`,
-                lp.`post_created` AS `response_created`,
-                lu.`user_id` AS `respondent_id`,
-                lu.`username` AS `respondent_name`,
-                COALESCE(lu.`user_colour`, lr.`role_colour`) AS `respondent_colour`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    %5$s
-                ) AS `topic_count_posts`,
-                (
-                    SELECT CEIL(COUNT(`post_id`) / %6$d)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    %5$s
-                ) AS `topic_pages`,
-                (
-                    SELECT
-                        `target_user_id` > 0
-                    AND
-                        t.`topic_bumped` > NOW() - INTERVAL 1 MONTH
-                    AND (
-                        SELECT COUNT(ti.`topic_id`) < 1
-                        FROM `msz_forum_topics_track` AS tt
-                        RIGHT JOIN `msz_forum_topics` AS ti
-                        ON ti.`topic_id` = tt.`topic_id`
-                        WHERE ti.`topic_id` = t.`topic_id`
-                        AND tt.`user_id` = `target_user_id`
-                        AND `track_last_read` >= `topic_bumped`
-                    )
-                ) AS `topic_unread`,
-                (
-                    SELECT COUNT(`post_id`) > 0
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    AND `user_id` = `target_user_id`
-                    LIMIT 1
-                ) AS `topic_participated`
-            FROM `msz_forum_topics` AS t
-            LEFT JOIN `msz_forum_categories` AS f
-            ON f.`forum_id` = t.`forum_id`
-            LEFT JOIN `msz_users` AS au
-            ON t.`user_id` = au.`user_id`
-            LEFT JOIN `msz_roles` AS ar
-            ON ar.`role_id` = au.`display_role`
-            LEFT JOIN `msz_forum_posts` AS lp
-            ON lp.`post_id` = (
-                SELECT `post_id`
-                FROM `msz_forum_posts`
-                WHERE `topic_id` = t.`topic_id`
-                %5$s
-                ORDER BY `post_id` DESC
-                LIMIT 1
-            )
-            LEFT JOIN `msz_users` AS lu
-            ON lu.`user_id` = lp.`user_id`
-            LEFT JOIN `msz_roles` AS lr
-            ON lr.`role_id` = lu.`display_role`
-            WHERE au.`user_id` = :author_id
-            %1$s
-            ORDER BY FIELD(t.`topic_type`, %4$s) DESC, t.`topic_bumped` DESC
-            %2$s
-        ',
-        $showDeleted ? '' : 'AND t.`topic_deleted` IS NULL',
-        $hasPagination ? 'LIMIT :offset, :take' : '',
-        MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT,
-        implode(',', array_reverse(MSZ_TOPIC_TYPE_ORDER)),
-        $showDeleted ? '' : 'AND `post_deleted` IS NULL',
-        MSZ_FORUM_POSTS_PER_PAGE
-    ));
-    $getTopics->bind('author_id', $authorId);
-    $getTopics->bind('user_id', $userId);
-
-    if($hasPagination) {
-        $getTopics->bind('offset', $offset);
-        $getTopics->bind('take', $take);
-    }
-
-    return $getTopics->fetchAll();
-}
-
-function forum_topic_listing_search(string $query, int $userId): array {
-    $getTopics = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                :user_id AS `target_user_id`,
-                t.`topic_id`, t.`topic_title`, t.`topic_locked`, t.`topic_type`, t.`topic_created`,
-                t.`topic_bumped`, t.`topic_deleted`, t.`topic_count_views`, f.`forum_type`,
-                au.`user_id` AS `author_id`, au.`username` AS `author_name`,
-                COALESCE(au.`user_colour`, ar.`role_colour`) AS `author_colour`,
-                lp.`post_id` AS `response_id`,
-                lp.`post_created` AS `response_created`,
-                lu.`user_id` AS `respondent_id`,
-                lu.`username` AS `respondent_name`,
-                COALESCE(lu.`user_colour`, lr.`role_colour`) AS `respondent_colour`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    AND `post_deleted` IS NULL
-                ) AS `topic_count_posts`,
-                (
-                    SELECT CEIL(COUNT(`post_id`) / %2$d)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    AND `post_deleted` IS NULL
-                ) AS `topic_pages`,
-                (
-                    SELECT
-                        `target_user_id` > 0
-                    AND
-                        t.`topic_bumped` > NOW() - INTERVAL 1 MONTH
-                    AND (
-                        SELECT COUNT(ti.`topic_id`) < 1
-                        FROM `msz_forum_topics_track` AS tt
-                        RIGHT JOIN `msz_forum_topics` AS ti
-                        ON ti.`topic_id` = tt.`topic_id`
-                        WHERE ti.`topic_id` = t.`topic_id`
-                        AND tt.`user_id` = `target_user_id`
-                        AND `track_last_read` >= `topic_bumped`
-                    )
-                ) AS `topic_unread`,
-                (
-                    SELECT COUNT(`post_id`) > 0
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = t.`topic_id`
-                    AND `user_id` = `target_user_id`
-                    LIMIT 1
-                ) AS `topic_participated`
-            FROM `msz_forum_topics` AS t
-            LEFT JOIN `msz_forum_categories` AS f
-            ON f.`forum_id` = t.`forum_id`
-            LEFT JOIN `msz_users` AS au
-            ON t.`user_id` = au.`user_id`
-            LEFT JOIN `msz_roles` AS ar
-            ON ar.`role_id` = au.`display_role`
-            LEFT JOIN `msz_forum_posts` AS lp
-            ON lp.`post_id` = (
-                SELECT `post_id`
-                FROM `msz_forum_posts`
-                WHERE `topic_id` = t.`topic_id`
-                AND `post_deleted` IS NULL
-                ORDER BY `post_id` DESC
-                LIMIT 1
-            )
-            LEFT JOIN `msz_users` AS lu
-            ON lu.`user_id` = lp.`user_id`
-            LEFT JOIN `msz_roles` AS lr
-            ON lr.`role_id` = lu.`display_role`
-            WHERE MATCH(`topic_title`)
-            AGAINST (:query IN NATURAL LANGUAGE MODE)
-            AND t.`topic_deleted` IS NULL
-            ORDER BY FIELD(t.`topic_type`, %1$s) DESC, t.`topic_bumped` DESC
-        ',
-        implode(',', array_reverse(MSZ_TOPIC_TYPE_ORDER)),
-        MSZ_FORUM_POSTS_PER_PAGE
-    ));
-    $getTopics->bind('query', $query);
-    $getTopics->bind('user_id', $userId);
-
-    return $getTopics->fetchAll();
-}
-
-function forum_topic_lock(int $topicId): bool {
-    if($topicId < 1) {
-        return false;
-    }
-
-    $markLocked = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_topics`
-        SET `topic_locked` = NOW()
-        WHERE `topic_id` = :topic
-        AND `topic_locked` IS NULL
-    ');
-    $markLocked->bind('topic', $topicId);
-
-    return $markLocked->execute();
-}
-
-function forum_topic_unlock(int $topicId): bool {
-    if($topicId < 1) {
-        return false;
-    }
-
-    $markUnlocked = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_topics`
-        SET `topic_locked` = NULL
-        WHERE `topic_id` = :topic
-        AND `topic_locked` IS NOT NULL
-    ');
-    $markUnlocked->bind('topic', $topicId);
-
-    return $markUnlocked->execute();
-}
-
-define('MSZ_E_FORUM_TOPIC_DELETE_OK', 0);       // deleting is fine
-define('MSZ_E_FORUM_TOPIC_DELETE_USER', 1);     // invalid user
-define('MSZ_E_FORUM_TOPIC_DELETE_TOPIC', 2);    // topic doesn't exist
-define('MSZ_E_FORUM_TOPIC_DELETE_DELETED', 3);  // topic is already marked as deleted
-define('MSZ_E_FORUM_TOPIC_DELETE_OWNER', 4);    // you may only delete your own topics
-define('MSZ_E_FORUM_TOPIC_DELETE_OLD', 5);      // topic has existed for too long to be deleted
-define('MSZ_E_FORUM_TOPIC_DELETE_PERM', 6);     // you aren't allowed to delete topics
-define('MSZ_E_FORUM_TOPIC_DELETE_POSTS', 7);    // the topic already has replies
-
-// only allow topics made within a day of posting to be deleted by normal users
-define('MSZ_FORUM_TOPIC_DELETE_TIME_LIMIT', 60 * 60 * 24);
-
-// only allow topics with a single post to be deleted, includes soft deleted posts
-define('MSZ_FORUM_TOPIC_DELETE_POST_LIMIT', 1);
-
-// set $userId to null for system request, make sure this is NEVER EVER null on user request
-// $topicId can also be a the return value of forum_topic_get if you already grabbed it once before
-function forum_topic_can_delete($topicId, ?int $userId = null): int {
-    if($userId !== null && $userId < 1) {
-        return MSZ_E_FORUM_TOPIC_DELETE_USER;
-    }
-
-    if(is_array($topicId)) {
-        $topic = $topicId;
-    } else {
-        $topic = forum_topic_get((int)$topicId, true);
-    }
-
-    if(empty($topic)) {
-        return MSZ_E_FORUM_TOPIC_DELETE_TOPIC;
-    }
-
-    $isSystemReq    = $userId === null;
-    $perms          = $isSystemReq ? 0      : forum_perms_get_user($topic['forum_id'], $userId)[MSZ_FORUM_PERMS_GENERAL];
-    $canDeleteAny   = $isSystemReq ? true   : perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
-    $canViewPost    = $isSystemReq ? true   : perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM);
-    $postIsDeleted  = !empty($topic['topic_deleted']);
-
-    if(!$canViewPost) {
-        return MSZ_E_FORUM_TOPIC_DELETE_TOPIC;
-    }
-
-    if($postIsDeleted) {
-        return $canDeleteAny ? MSZ_E_FORUM_TOPIC_DELETE_DELETED : MSZ_E_FORUM_TOPIC_DELETE_TOPIC;
-    }
-
-    if($isSystemReq) {
-        return MSZ_E_FORUM_TOPIC_DELETE_OK;
-    }
-
-    if(!$canDeleteAny) {
-        if(!perms_check($perms, MSZ_FORUM_PERM_DELETE_POST)) {
-            return MSZ_E_FORUM_TOPIC_DELETE_PERM;
-        }
-
-        if($topic['author_user_id'] !== $userId) {
-            return MSZ_E_FORUM_TOPIC_DELETE_OWNER;
-        }
-
-        if(strtotime($topic['topic_created']) <= time() - MSZ_FORUM_TOPIC_DELETE_TIME_LIMIT) {
-            return MSZ_E_FORUM_TOPIC_DELETE_OLD;
-        }
-
-        $totalReplies = $topic['topic_count_posts'] + $topic['topic_count_posts_deleted'];
-
-        if($totalReplies > MSZ_E_FORUM_TOPIC_DELETE_POSTS) {
-            return MSZ_E_FORUM_TOPIC_DELETE_POSTS;
-        }
-    }
-
-    return MSZ_E_FORUM_TOPIC_DELETE_OK;
-}
-
-function forum_topic_delete(int $topicId): bool {
-    if($topicId < 1) {
-        return false;
-    }
-
-    $markTopicDeleted = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_topics`
-        SET `topic_deleted` = NOW()
-        WHERE `topic_id` = :topic
-        AND `topic_deleted` IS NULL
-    ');
-    $markTopicDeleted->bind('topic', $topicId);
-
-    if(!$markTopicDeleted->execute()) {
-        return false;
-    }
-
-    $markPostsDeleted = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_posts` as p
-        SET p.`post_deleted` = (
-            SELECT `topic_deleted`
-            FROM `msz_forum_topics`
-            WHERE `topic_id` = p.`topic_id`
-        )
-        WHERE p.`topic_id` = :topic
-        AND p.`post_deleted` IS NULL
-    ');
-    $markPostsDeleted->bind('topic', $topicId);
-
-    return $markPostsDeleted->execute();
-}
-
-function forum_topic_restore(int $topicId): bool {
-    if($topicId < 1) {
-        return false;
-    }
-
-    $markPostsRestored = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_posts` as p
-        SET p.`post_deleted` = NULL
-        WHERE p.`topic_id` = :topic
-        AND p.`post_deleted` = (
-            SELECT `topic_deleted`
-            FROM `msz_forum_topics`
-            WHERE `topic_id` = p.`topic_id`
-        )
-    ');
-    $markPostsRestored->bind('topic', $topicId);
-
-    if(!$markPostsRestored->execute()) {
-        return false;
-    }
-
-    $markTopicRestored = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_topics`
-        SET `topic_deleted` = NULL
-        WHERE `topic_id` = :topic
-        AND `topic_deleted` IS NOT NULL
-    ');
-    $markTopicRestored->bind('topic', $topicId);
-
-    return $markTopicRestored->execute();
-}
-
-function forum_topic_nuke(int $topicId): bool {
-    if($topicId < 1) {
-        return false;
-    }
-
-    $nukeTopic = \Misuzu\DB::prepare('
-        DELETE FROM `msz_forum_topics`
-        WHERE `topic_id` = :topic
-    ');
-    $nukeTopic->bind('topic', $topicId);
-    return $nukeTopic->execute();
-}
-
-function forum_topic_priority(int $topic): array {
-    if($topic < 1) {
-        return [];
-    }
-
-    $getPriority = \Misuzu\DB::prepare('
-        SELECT
-            tp.`topic_id`, tp.`topic_priority`,
-            u.`user_id`, u.`username`,
-            COALESCE(u.`user_colour`, r.`role_colour`) AS `user_colour`
-        FROM `msz_forum_topics_priority` AS tp
-        LEFT JOIN `msz_users` AS u
-        ON u.`user_id` = tp.`user_id`
-        LEFT JOIN `msz_roles` AS r
-        ON u.`display_role` = r.`role_id`
-        WHERE `topic_id` = :topic
-    ');
-    $getPriority->bind('topic', $topic);
-
-    return $getPriority->fetchAll();
-}
-
-function forum_topic_priority_increase(int $topic, int $user, int $bump = 1): void {
-    if($topic < 1 || $user < 1 || $bump === 0) {
-        return;
-    }
-
-    $bumpPriority = \Misuzu\DB::prepare('
-        INSERT INTO `msz_forum_topics_priority`
-            (`topic_id`, `user_id`, `topic_priority`)
-        VALUES
-            (:topic, :user, :bump1)
-        ON DUPLICATE KEY UPDATE
-            `topic_priority` = `topic_priority` + :bump2
-    ');
-    $bumpPriority->bind('topic', $topic);
-    $bumpPriority->bind('user', $user);
-    $bumpPriority->bind('bump1', $bump);
-    $bumpPriority->bind('bump2', $bump);
-    $bumpPriority->execute();
-}
diff --git a/src/Forum/validate.php b/src/Forum/validate.php
deleted file mode 100644
index 5c0a6ba9..00000000
--- a/src/Forum/validate.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-define('MSZ_TOPIC_TITLE_LENGTH_MIN', 3);
-define('MSZ_TOPIC_TITLE_LENGTH_MAX', 100);
-define('MSZ_POST_TEXT_LENGTH_MIN', 1);
-define('MSZ_POST_TEXT_LENGTH_MAX', 60000);
-
-function forum_validate_title(string $title): string {
-    $length = mb_strlen(trim($title));
-
-    if($length < MSZ_TOPIC_TITLE_LENGTH_MIN) {
-        return 'too-short';
-    }
-
-    if($length > MSZ_TOPIC_TITLE_LENGTH_MAX) {
-        return 'too-long';
-    }
-
-    return '';
-}
-
-function forum_validate_post(string $text): string {
-    $length = mb_strlen(trim($text));
-
-    if($length < MSZ_POST_TEXT_LENGTH_MIN) {
-        return 'too-short';
-    }
-
-    if($length > MSZ_POST_TEXT_LENGTH_MAX) {
-        return 'too-long';
-    }
-
-    return '';
-}
diff --git a/src/Http/Handlers/Forum/ForumCategoryHandler.php b/src/Http/Handlers/Forum/ForumCategoryHandler.php
new file mode 100644
index 00000000..587883c9
--- /dev/null
+++ b/src/Http/Handlers/Forum/ForumCategoryHandler.php
@@ -0,0 +1,91 @@
+<?php
+namespace Misuzu\Http\Handlers\Forum;
+
+use HttpResponse;
+use HttpRequest;
+use Misuzu\Pagination;
+use Misuzu\Forum\ForumCategory;
+use Misuzu\Forum\ForumCategoryNotFoundException;
+use Misuzu\Users\User;
+
+class ForumCategoryHandler extends ForumHandler {
+    public function category(HttpResponse $response, HttpRequest $request, int $categoryId) {
+        if($categoryId === 0) {
+            $response->redirect(url('forum-index'));
+            return;
+        }
+
+        try {
+            $categoryInfo = ForumCategory::byId($categoryId);
+        } catch(ForumCategoryNotFoundException $ex) {}
+
+        if(empty($categoryInfo) || ($categoryInfo->isLink() && !$categoryInfo->hasLink()))
+            return 404;
+
+        $currentUser = User::getCurrent();
+
+        if(!$categoryInfo->canView($currentUser))
+            return 403;
+
+        $perms = forum_perms_get_user($categoryInfo->getId(), $currentUser === null ? 0 : $currentUser->getId())[MSZ_FORUM_PERMS_GENERAL];
+
+        if(isset($currentUser) && $currentUser->hasActiveWarning())
+            $perms &= ~MSZ_FORUM_PERM_SET_WRITE;
+
+        if($categoryInfo->isLink()) {
+            $categoryInfo->increaseLinkClicks();
+            $response->redirect($categoryInfo->getLink());
+            return;
+        }
+
+        $canViewDeleted = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
+        $pagination = new Pagination($categoryInfo->getActualTopicCount($canViewDeleted), 20);
+
+        if(!$pagination->hasValidOffset() && $pagination->getCount() > 0)
+            return 404;
+
+        $response->setTemplate('forum.forum', [
+            'forum_perms' => $perms,
+            'forum_info' => $categoryInfo,
+            'forum_pagination' => $pagination,
+            'can_view_deleted' => $canViewDeleted,
+        ]);
+    }
+
+    public function createView(HttpResponse $response, HttpRequest $request, int $categoryId) {
+        try {
+            $categoryInfo = ForumCategory::byId($categoryId);
+        } catch(ForumCategoryNotFoundException $ex) {
+            return 404;
+        }
+
+        var_dump($categoryInfo->getId());
+    }
+
+    public function createAction(HttpResponse $response, HttpRequest $request, int $categoryId) {
+        try {
+            $categoryInfo = ForumCategory::byId($categoryId);
+        } catch(ForumCategoryNotFoundException $ex) {
+            return 404;
+        }
+
+        var_dump($categoryInfo->getId());
+    }
+
+    public function legacy(HttpResponse $response, HttpRequest $request) {
+        $categoryId = (int)$request->getQueryParam('f', FILTER_SANITIZE_NUMBER_INT);
+
+        if($categoryId < 0)
+            return 404;
+
+        if($categoryId === 0) {
+            $response->redirect(url('forum-index'));
+            return;
+        }
+
+        $response->redirect(url('forum-category', [
+            'forum' => $categoryId,
+            'page'  => (int)$request->getQueryParam('p', FILTER_SANITIZE_NUMBER_INT),
+        ]));
+    }
+}
diff --git a/src/Http/Handlers/Forum/ForumHandler.php b/src/Http/Handlers/Forum/ForumHandler.php
new file mode 100644
index 00000000..3fec15d0
--- /dev/null
+++ b/src/Http/Handlers/Forum/ForumHandler.php
@@ -0,0 +1,6 @@
+<?php
+namespace Misuzu\Http\Handlers\Forum;
+
+use Misuzu\Http\Handlers\Handler;
+
+abstract class ForumHandler extends Handler {}
diff --git a/src/Http/Handlers/Forum/ForumIndexHandler.php b/src/Http/Handlers/Forum/ForumIndexHandler.php
new file mode 100644
index 00000000..a1242020
--- /dev/null
+++ b/src/Http/Handlers/Forum/ForumIndexHandler.php
@@ -0,0 +1,56 @@
+<?php
+namespace Misuzu\Http\Handlers\Forum;
+
+use HttpResponse;
+use HttpRequest;
+use Misuzu\Forum\ForumCategory;
+use Misuzu\Forum\ForumCategoryNotFoundException;
+use Misuzu\Users\User;
+
+class ForumIndexHandler extends ForumHandler {
+    public function index(HttpResponse $response): void {
+        $response->setTemplate('forum.index', [
+            'forum_root' => ForumCategory::root(),
+        ]);
+    }
+
+    public function markAsRead(HttpResponse $response, HttpRequest $request) {
+        try {
+            $categoryInfo = ForumCategory::byId(
+                (int)($request->getBodyParam('forum', FILTER_SANITIZE_NUMBER_INT) ?? $request->getQueryParam('forum', FILTER_SANITIZE_NUMBER_INT))
+            );
+        } catch(ForumCategoryNotFoundException $ex) {
+            return 404;
+        }
+
+        if($request->getMethod() === 'GET') {
+            $response->setTemplate('confirm', [
+                'title' => 'Mark forum as read',
+                'message' => 'Are you sure you want to mark ' . ($categoryInfo->isRoot() ? 'the entire' : 'this') . ' forum as read?',
+                'return' => url($categoryInfo->isRoot() ? 'forum-index' : 'forum-category', ['forum' => $categoryInfo->getId()]),
+                'params' => [
+                    'forum' => $categoryInfo->getId(),
+                ]
+            ]);
+            return;
+        }
+
+        $categoryInfo->markAsRead(User::getCurrent());
+
+        $response->redirect(
+            url($categoryInfo->isRoot() ? 'forum-index' : 'forum-category', ['forum' => $categoryInfo->getId()]),
+            false,
+            $request->hasHeader('X-Misuzu-XHR')
+        );
+    }
+
+    public function legacy(HttpResponse $response, HttpRequest $request): void {
+        if($request->getQueryParam('m') === 'mark') {
+            $forumId = (int)$request->getQueryParam('f', FILTER_SANITIZE_NUMBER_INT);
+            $response->redirect(url($forumId < 1 ? 'forum-mark-global' : 'forum-mark-single', ['forum' => $forumId]));
+            return;
+        }
+
+        $response->redirect(url('forum-index'));
+    }
+}
diff --git a/src/Http/Handlers/Forum/ForumPollHandler.php b/src/Http/Handlers/Forum/ForumPollHandler.php
new file mode 100644
index 00000000..7f6e1371
--- /dev/null
+++ b/src/Http/Handlers/Forum/ForumPollHandler.php
@@ -0,0 +1,32 @@
+<?php
+namespace Misuzu\Http\Handlers\Forum;
+
+use HttpResponse;
+use HttpRequest;
+use Misuzu\Forum\ForumPoll;
+use Misuzu\Forum\ForumPollNotFoundException;
+use Misuzu\Users\User;
+
+class ForumPollHandler extends ForumHandler {
+    public function vote(HttpResponse $response, HttpRequest $request, int $postId) {
+        try {
+            $pollInfo = ForumPoll::byId($pollId);
+        } catch(ForumPollNotFoundException $ex) {
+            return 404;
+        }
+
+        // check perms lol
+
+        $results = [];
+
+        foreach($pollInfo->getOptions() as $optionInfo)
+            $results[] = [
+                'id' => $optionInfo->getId(),
+                'text' => $optionInfo->getText(),
+                'vote_count' => $optionInfo->getVotes(),
+                'vote_percent' => $optionInfo->getPercentage(),
+            ];
+
+        return $results;
+    }
+}
diff --git a/src/Http/Handlers/Forum/ForumPostHandler.php b/src/Http/Handlers/Forum/ForumPostHandler.php
new file mode 100644
index 00000000..51da1ac3
--- /dev/null
+++ b/src/Http/Handlers/Forum/ForumPostHandler.php
@@ -0,0 +1,43 @@
+<?php
+namespace Misuzu\Http\Handlers\Forum;
+
+use HttpResponse;
+use HttpRequest;
+use Misuzu\Pagination;
+use Misuzu\Forum\ForumPost;
+use Misuzu\Forum\ForumPostNotFoundException;
+use Misuzu\Users\User;
+
+class ForumPostHandler extends ForumHandler {
+    public function post(HttpResponse $response, HttpRequest $request, int $postId) {
+        try {
+            $postInfo = ForumPost::byId($postId);
+        } catch(ForumPostNotFoundException $ex) {
+            return 404;
+        }
+
+        var_dump($postInfo->getId());
+    }
+
+    public function edit(HttpResponse $response, HttpRequest $request, int $postId) {
+    }
+
+    public function delete(HttpResponse $response, HttpRequest $request, int $postId) {
+    }
+
+    public function restore(HttpResponse $response, HttpRequest $request, int $postId) {
+    }
+
+    public function nuke(HttpResponse $response, HttpRequest $request, int $postId) {
+    }
+
+    public function legacy(HttpResponse $response, HttpRequest $request) {
+        $postId  = (int)$request->getQueryParam('p', FILTER_SANITIZE_NUMBER_INT);
+        if($postId > 0) {
+            $response->redirect(url('forum-post', ['post' => $postId]));
+            return;
+        }
+
+        return 404;
+    }
+}
diff --git a/src/Http/Handlers/Forum/ForumTopicHandler.php b/src/Http/Handlers/Forum/ForumTopicHandler.php
new file mode 100644
index 00000000..0e3ff1e7
--- /dev/null
+++ b/src/Http/Handlers/Forum/ForumTopicHandler.php
@@ -0,0 +1,132 @@
+<?php
+namespace Misuzu\Http\Handlers\Forum;
+
+use HttpResponse;
+use HttpRequest;
+use Misuzu\Pagination;
+use Misuzu\Forum\ForumTopic;
+use Misuzu\Forum\ForumTopicNotFoundException;
+use Misuzu\Users\User;
+
+class ForumTopicHandler extends ForumHandler {
+    public function topic(HttpResponse $response, HttpRequest $request, int $topicId) {
+        try {
+            $topicInfo = ForumTopic::byId($topicId);
+        } catch(ForumTopicNotFoundException $ex) {
+            return 404;
+        }
+
+        var_dump($topicInfo->getId());
+    }
+
+    public function reply(HttpResponse $response, HttpRequest $request, int $topicId) {
+    }
+
+    // Should support a since param to fetch a number of points after a point in time/post id
+    public function live(HttpResponse $response, HttpRequest $request, int $topicId) {
+        try {
+            $topicInfo = ForumTopic::byId($topicId);
+        } catch(ForumTopicNotFoundException $ex) {
+            return 404;
+        }
+
+        if(!$topicInfo->getCategory()->canView(User::getCurrent()))
+            return 403;
+
+        $sincePostId = (int)($request->getQueryParam('since', FILTER_SANITIZE_NUMBER_INT) ?? -1);
+
+        $ajaxInfo = [
+            'id' => $topicInfo->getId(),
+            'title' => $topicInfo->getTitle(),
+        ];
+
+        $categoryInfo = $topicInfo->getCategory();
+        $ajaxInfo['category'] = [
+            'id' => $categoryInfo->getId(),
+            'name' => $categoryInfo->getName(),
+            'tree' => [],
+        ];
+        
+        $parentTree = $categoryInfo->getParentTree();
+        foreach($parentTree as $parentInfo)
+            $ajaxInfo['category']['tree'][] = [
+                'id' => $parentInfo->getId(),
+                'name' => $parentInfo->getName(),
+            ];
+
+        if($topicInfo->hasPriorityVoting()) {
+            $ajaxInfo['priority'] = [
+                'total' => $topicInfo->getPriority(),
+                'votes' => [],
+            ];
+            $topicPriority = $topicInfo->getPriorityVotes();
+            foreach($topicPriority as $priorityInfo) {
+                $priorityUserInfo = $priorityInfo->getUser();
+                $ajaxInfo['priority']['votes'][] = [
+                    'count' => $priorityInfo->getPriority(),
+                    'user' => [
+                        'id' => $priorityUserInfo->getId(),
+                        'name' => $priorityUserInfo->getUsername(),
+                        'colour' => $priorityUserInfo->getColour()->getRaw(),
+                    ],
+                ];
+            }
+        }
+
+        if($topicInfo->hasPoll()) {
+            $pollInfo = $topicInfo->getPoll();
+            $ajaxInfo['poll'] = [
+                'id' => $pollInfo->getId(),
+                'options' => [],
+            ];
+
+            $pollOptions = $pollInfo->getOptions();
+            foreach($pollOptions as $optionInfo)
+                $ajaxInfo['poll']['options'][] = [
+                    'id' => $optionInfo->getId(),
+                    'text' => $optionInfo->getText(),
+                    'vote_count' => $optionInfo->getVotes(),
+                    'vote_percent' => $optionInfo->getPercentage(),
+                ];
+        }
+
+        if($sincePostId >= 0) {
+            // Should contain all info necessary to build said posts
+            // Maybe just serialised HTML a la YTKNS?
+            $ajaxInfo['posts'] = [];
+        }
+
+        return $ajaxInfo;
+    }
+
+    public function delete(HttpResponse $response, HttpRequest $request, int $topicId) {
+    }
+
+    public function restore(HttpResponse $response, HttpRequest $request, int $topicId) {
+    }
+
+    public function nuke(HttpResponse $response, HttpRequest $request, int $topicId) {
+    }
+
+    public function bump(HttpResponse $response, HttpRequest $request, int $topicId) {
+    }
+
+    public function lock(HttpResponse $response, HttpRequest $request, int $topicId) {
+    }
+
+    public function legacy(HttpResponse $response, HttpRequest $request) {
+        $postId  = (int)$request->getQueryParam('p', FILTER_SANITIZE_NUMBER_INT);
+        if($postId > 0) {
+            $response->redirect(url('forum-post', ['post' => $postId]));
+            return;
+        }
+
+        $topicId = (int)$request->getQueryParam('t', FILTER_SANITIZE_NUMBER_INT);
+        if($topicId > 0) {
+            $response->redirect(url('forum-topic', ['topic' => $topicId]));
+            return;
+        }
+
+        return 404;
+    }
+}
diff --git a/src/Http/Handlers/ForumHandler.php b/src/Http/Handlers/ForumHandler.php
deleted file mode 100644
index 9afb40b1..00000000
--- a/src/Http/Handlers/ForumHandler.php
+++ /dev/null
@@ -1,32 +0,0 @@
-<?php
-namespace Misuzu\Http\Handlers;
-
-use HttpResponse;
-use HttpRequest;
-use Misuzu\CSRF;
-use Misuzu\Users\User;
-
-final class ForumHandler extends Handler {
-    public function markAsReadGET(HttpResponse $response, HttpRequest $request): void {
-        $forumId = (int)$request->getQueryParam('forum', FILTER_SANITIZE_NUMBER_INT);
-        $response->setTemplate('confirm', [
-            'title' => 'Mark forum as read',
-            'message' => 'Are you sure you want to mark ' . ($forumId === null ? 'the entire' : 'this') . ' forum as read?',
-            'return' => url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]),
-            'params' => [
-                'forum' => $forumId,
-            ]
-        ]);
-    }
-
-    public function markAsReadPOST(HttpResponse $response, HttpRequest $request) {
-        $forumId = (int)$request->getBodyParam('forum', FILTER_SANITIZE_NUMBER_INT);
-        forum_mark_read($forumId, User::getCurrent()->getId());
-
-        $response->redirect(
-            url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]),
-            false,
-            $request->hasHeader('X-Misuzu-XHR')
-        );
-    }
-}
diff --git a/src/Http/Routing/Route.php b/src/Http/Routing/Route.php
index ccccb784..6444d52e 100644
--- a/src/Http/Routing/Route.php
+++ b/src/Http/Routing/Route.php
@@ -19,7 +19,8 @@ class Route implements Serializable {
     public function __construct(array $methods, string $path, ?string $method = null, ?string $class = null) {
         $this->methods = array_map('strtoupper', $methods);
         $this->path = $path;
-        $this->handlerClass = $class;
+        if($class !== null)
+            $this->handlerClass = str_replace('.', '\\', $class);
         $this->handlerMethod = $method;
     }
 
diff --git a/src/Memoizer.php b/src/Memoizer.php
index ffcb3ad7..f6faa2ee 100644
--- a/src/Memoizer.php
+++ b/src/Memoizer.php
@@ -15,13 +15,20 @@ class Memoizer {
 
         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;
+            if($item !== null)
+                $this->insert($item);
             return $item;
         }
 
         throw new InvalidArgumentException('Wasn\'t able to figure out your $find argument.');
     }
+
+    public function insert($item): void {
+        if($item === null)
+            throw new InvalidArgumentException('null');
+        if(method_exists($item, 'getId'))
+            $this->collection[$item->getId()] = $item;
+        else
+            $this->collection[] = $item;
+    }
 }
diff --git a/src/Pagination.php b/src/Pagination.php
index 213d44a4..10042704 100644
--- a/src/Pagination.php
+++ b/src/Pagination.php
@@ -74,4 +74,60 @@ final class Pagination {
 
         return $default;
     }
+
+    private const PAGE_RANGE = 5;
+
+    public function render(string $pathOrName, array $params = [], string $pageParam = self::DEFAULT_PARAM, string $urlFragment = ''): string {
+        if($this->getPages() <= 1)
+            return '';
+
+        if($pathOrName[0] !== '/')
+            $pathOrName = url($pathOrName);
+
+        $getUrl = function(int $page) use ($pathOrName, $params, $pageParam, $urlFragment) {
+            if($page <= 1)
+                unset($params[$pageParam]);
+            else
+                $params[$pageParam] = $page;
+
+            $url = $pathOrName;
+            if(!empty($params))
+                $url .= '?' . http_build_query($params);
+            if(!empty($urlFragment))
+                $url .= '#' . rawurldecode($urlFragment);
+
+            return $url;
+        };
+
+        $html = '<div class="pagination">';
+
+        $html .= '<div class="pagination__section pagination__section--first">';
+        if($this->getPage() <= 1) {
+            $html .= '<div class="pagination__link pagination__link--first pagination__link--disabled"><i class="fas fa-angle-double-left"></i></div>';
+            $html .= '<div class="pagination__link pagination__link--prev pagination__link--disabled"><i class="fas fa-angle-left"></i></div>';
+        } else {
+            $html .= '<a href="'  . $getUrl(1) . '" class="pagination__link pagination__link--first" rel="first"><i class="fas fa-angle-double-left"></i></a>';
+            $html .= '<a href="'  . $getUrl($this->getPage() - 1) . '" class="pagination__link pagination__link--prev" rel="prev"><i class="fas fa-angle-left"></i></a>';
+        }
+        $html .= '</div>';
+
+        $html .= '<div class="pagination__section pagination__section--pages">';
+        $start = max($this->getPage() - self::PAGE_RANGE, 1);
+        $stop = min($this->getPage() + self::PAGE_RANGE, $this->getPages());
+        for($i = $start; $i <= $stop; ++$i)
+            $html .= '<a href="' . $getUrl($i) . '" class="pagination__link' . ($i === $this->getPage() ? ' pagination__link--current' : '') . '">' . number_format($i) . '</a>';
+        $html .= '</div>';
+
+        $html .= '<div class="pagination__section pagination__section--last">';
+        if($this->getPage() >= $this->getPages()) {
+            $html .= '<div class="pagination__link pagination__link--next pagination__link--disabled"><i class="fas fa-angle-right"></i></div>';
+            $html .= '<div class="pagination__link pagination__link--last pagination__link--disabled"><i class="fas fa-angle-double-right"></i></div>';
+        } else {
+            $html .= '<a href="'  . $getUrl($this->getPage() + 1) . '" class="pagination__link pagination__link--next" rel="next"><i class="fas fa-angle-right"></i></a>';
+            $html .= '<a href="'  . $getUrl($this->getPages()) . '" class="pagination__link pagination__link--last" rel="last"><i class="fas fa-angle-double-right"></i></a>';
+        }
+        $html .= '</div>';
+
+        return $html . '</div>';
+    }
 }
diff --git a/src/TwigMisuzu.php b/src/TwigMisuzu.php
index 6dc30fde..fbdf2501 100644
--- a/src/TwigMisuzu.php
+++ b/src/TwigMisuzu.php
@@ -23,10 +23,6 @@ final class TwigMisuzu extends Twig_Extension {
             new Twig_Function('url_construct', 'url_construct'),
             new Twig_Function('url', 'url'),
             new Twig_Function('url_list', 'url_list'),
-            new Twig_Function('html_avatar', 'html_avatar'),
-            new Twig_Function('forum_may_have_children', 'forum_may_have_children'),
-            new Twig_Function('forum_may_have_topics', 'forum_may_have_topics'),
-            new Twig_Function('forum_has_priority_voting', 'forum_has_priority_voting'),
             new Twig_Function('csrf_token', fn() => CSRF::token()),
             new Twig_Function('git_commit_hash', fn(bool $long = false) => GitInfo::hash($long)),
             new Twig_Function('git_tag', fn() => GitInfo::tag()),
diff --git a/src/Users/User.php b/src/Users/User.php
index 419ff0b0..1caacf4f 100644
--- a/src/Users/User.php
+++ b/src/Users/User.php
@@ -10,6 +10,7 @@ use Misuzu\HasRankInterface;
 use Misuzu\Memoizer;
 use Misuzu\Pagination;
 use Misuzu\TOTP;
+use Misuzu\Forum\ForumCategory;
 use Misuzu\Net\IPAddress;
 use Misuzu\Parsers\Parser;
 use Misuzu\Users\Assets\UserAvatarAsset;
@@ -303,6 +304,11 @@ class User implements HasRankInterface, JsonSerializable {
         return Parser::instance($this->getForumSignatureParser())
             ->parseText(htmlspecialchars($this->getForumSignatureText()));
     }
+    public function getForumSignatureClasses(): string {
+        if($this->getForumSignatureParser() === Parser::MARKDOWN)
+            return 'markdown';
+        return '';
+    }
 
     // Address these through getBackgroundInfo()
     public function getBackgroundSettings(): int {
@@ -343,6 +349,17 @@ class User implements HasRankInterface, JsonSerializable {
         return intval($this->getBirthdate()->diff(new DateTime('now', new DateTimeZone('UTC')))->format('%y'));
     }
 
+    private $preferredParser = null;
+    public function getPreferredParser(): int {
+        if($this->preferredParser === null)
+            $this->preferredParser = DB::prepare(
+                'SELECT `post_parse` FROM `msz_forum_posts`'
+                . ' WHERE `user_id` = :user AND `post_deleted` IS NULL'
+                . ' ORDER BY `post_id` DESC LIMIT 1'
+            )->bind('user', $this->getId())->fetchColumn() ?? Parser::BBCODE;
+        return $this->preferredParser;
+    }
+
     public function profileFields(bool $filterEmpty = true): array {
         if(($userId = $this->getId()) < 1)
             return [];
@@ -883,7 +900,7 @@ class User implements HasRankInterface, JsonSerializable {
             return $user;
         });
     }
-    public static function byUsername(string $username): ?self {
+    public static function byUsername(string $username): self {
         $username = mb_strtolower($username);
         return self::memoizer()->find(function($user) use ($username) {
             return mb_strtolower($user->getUsername()) === $username;
@@ -896,7 +913,7 @@ class User implements HasRankInterface, JsonSerializable {
             return $user;
         });
     }
-    public static function byEMailAddress(string $address): ?self {
+    public static function byEMailAddress(string $address): self {
         $address = mb_strtolower($address);
         return self::memoizer()->find(function($user) use ($address) {
             return mb_strtolower($user->getEmailAddress()) === $address;
@@ -928,7 +945,7 @@ class User implements HasRankInterface, JsonSerializable {
         return DB::prepare(self::byQueryBase() . ' WHERE `user_deleted` IS NULL ORDER BY `user_id` DESC LIMIT 1')
             ->fetchObject(self::class);
     }
-    public static function findForProfile($userIdOrName): ?self {
+    public static function findForProfile($userIdOrName): self {
         $userIdOrNameLower = mb_strtolower($userIdOrName);
         return self::memoizer()->find(function($user) use ($userIdOrNameLower) {
             return $user->getId() == $userIdOrNameLower || mb_strtolower($user->getUsername()) === $userIdOrNameLower;
diff --git a/src/url.php b/src/url.php
index 7a2fdfaf..9def9439 100644
--- a/src/url.php
+++ b/src/url.php
@@ -45,7 +45,7 @@ define('MSZ_URLS', [
     'forum-mark-single'                 => ['/forum/mark-as-read',              ['forum' => '<forum>']],
     'forum-topic-new'                   => ['/forum/posting.php',               ['f' => '<forum>']],
     'forum-reply-new'                   => ['/forum/posting.php',               ['t' => '<topic>']],
-    'forum-category'                    => ['/forum/forum.php',                 ['f' => '<forum>', 'p' => '<page>']],
+    'forum-category'                    => ['/forum/<forum>',                   ['p' => '<page>']],
     'forum-topic'                       => ['/forum/topic.php',                 ['t' => '<topic>', 'page' => '<page>']],
     'forum-topic-create'                => ['/forum/posting.php',               ['f' => '<forum>']],
     'forum-topic-bump'                  => ['/forum/topic.php',                 ['t' => '<topic>', 'm' => 'bump', 'csrf' => '{csrf}']],
@@ -55,6 +55,7 @@ define('MSZ_URLS', [
     'forum-topic-restore'               => ['/forum/topic.php',                 ['t' => '<topic>', 'm' => 'restore', 'csrf' => '{csrf}']],
     'forum-topic-nuke'                  => ['/forum/topic.php',                 ['t' => '<topic>', 'm' => 'nuke', 'csrf' => '{csrf}']],
     'forum-topic-priority'              => ['/forum/topic-priority.php',        ['t' => '<topic>', 'b' => '<bump>']],
+    'forum-topic-live'                  => ['/forum/topic/<topic>/live',        ['since' => '<post>']],
     'forum-post'                        => ['/forum/topic.php',                 ['p' => '<post>'], '<post_fragment>'],
     'forum-post-create'                 => ['/forum/posting.php',               ['t' => '<topic>']],
     'forum-post-delete'                 => ['/forum/post.php',                  ['p' => '<post>', 'm' => 'delete']],
diff --git a/templates/changelog/index.twig b/templates/changelog/index.twig
index fb766f8c..75cddfc0 100644
--- a/templates/changelog/index.twig
+++ b/templates/changelog/index.twig
@@ -1,5 +1,5 @@
 {% extends 'changelog/master.twig' %}
-{% from 'macros.twig' import pagination, container_title %}
+{% from 'macros.twig' import container_title %}
 {% from 'changelog/macros.twig' import changelog_listing %}
 {% from '_layout/comments.twig' import comments_section %}
 
@@ -39,7 +39,7 @@
 
         {% if not is_date %}
             <div class="changelog__pagination">
-                {{ pagination(changelog_pagination, url('changelog-index'), null, {'date':changelog_date_fmt, 'user':changelog_user.id|default(0)})}}
+                {{ changelog_pagination.render('changelog-index', {'date':changelog_date_fmt, 'user':changelog_user.id|default(0)})|raw }}
             </div>
         {% endif %}
     </div>
diff --git a/templates/forum/forum.twig b/templates/forum/forum.twig
index 86186c95..09c0bd34 100644
--- a/templates/forum/forum.twig
+++ b/templates/forum/forum.twig
@@ -1,32 +1,30 @@
 {% extends 'forum/master.twig' %}
 {% from 'forum/macros.twig' import forum_category_listing, forum_topic_listing, forum_category_buttons, forum_header, forum_category_tools %}
 
-{% set title = forum_info.forum_name %}
+{% set title = forum_info.name %}
 {% set canonical_url = url('forum-category', {
-    'forum': forum_info.forum_id,
+    'forum': forum_info.id,
     'page': forum_pagination.page|default(0) > 1 ? forum_pagination.page : 0,
 }) %}
 
 {% block content %}
-    {{ forum_header(forum_info.forum_name, forum_breadcrumbs, true, canonical_url, [
+    {{ forum_header(forum_info, true, canonical_url, [
         {
             'html': '<i class="far fa-check-circle"></i> Mark as Read',
-            'url': url('forum-mark-single', {'forum': forum_info.forum_id}),
+            'url': url('forum-mark-single', {'forum': forum_info.id}),
             'display': current_user is defined,
             'method': 'POST',
         }
     ]) }}
 
-    {% if forum_may_have_children and forum_info.forum_subforums|length > 0 %}
-        {{ forum_category_listing(forum_info.forum_subforums, 'Forums') }}
-    {% endif %}
+    {{ forum_category_listing(forum_info, current_user, 'Forums') }}
 
-    {% if forum_may_have_topics %}
+    {% if forum_info.canHaveTopics %}
         {% set category_tools = forum_category_tools(forum_info, forum_perms, forum_pagination) %}
         {{ category_tools }}
-        {{ forum_topic_listing(forum_topics) }}
+        {{ forum_topic_listing(forum_info.topics(can_view_deleted, forum_pagination), null, current_user) }}
         {{ category_tools }}
     {% endif %}
 
-    {{ forum_header('', forum_breadcrumbs) }}
+    {{ forum_header(forum_info, false) }}
 {% endblock %}
diff --git a/templates/forum/index.twig b/templates/forum/index.twig
index e80ebcc8..638ba70b 100644
--- a/templates/forum/index.twig
+++ b/templates/forum/index.twig
@@ -3,21 +3,21 @@
 {% from 'forum/macros.twig' import forum_category_listing %}
 
 {% set title = 'Forum Listing' %}
-{% set canonical_url = '/forum/' %}
+{% set canonical_url = url('forum-index') %}
 
 {% block content %}
-    {% if not forum_empty %}
-        {% for category in forum_categories %}
-            {% if category.forum_children > 0 %}
-                {{ forum_category_listing(
-                    category.forum_subforums,
-                    category.forum_name,
-                    category.forum_colour,
-                    category.forum_id == constant('MSZ_FORUM_ROOT')
-                        ? ''
-                        : 'f' ~ category.forum_id,
-                    category.forum_icon|default('')
-                ) }}
+    {% if forum_root.children|length is empty %}
+        <div class="container">
+            {{ container_title('<i class="fas fa-comment-slash fa-fw"></i> Forums') }}
+
+            <div class="container__content">
+                <p>There are currently no visible forums.</p>
+            </div>
+        </div>
+    {% else %}
+        {% for category in forum_root.children %}
+            {% if category.children is not empty %}
+                {{ forum_category_listing(category, current_user|default(null)) }}
             {% endif %}
         {% endfor %}
 
@@ -26,13 +26,5 @@
                 <a href="{{ url('forum-mark-global') }}" class="input__button forum__actions__button" data-msz-method="POST">Mark All Read</a>
             </div>
         {% endif %}
-    {% else %}
-        <div class="container">
-            {{ container_title('<i class="fas fa-comment-slash fa-fw"></i> Forums') }}
-
-            <div class="container__content">
-                <p>There are currently no visible forums.</p>
-            </div>
-        </div>
     {% endif %}
 {% endblock %}
diff --git a/templates/forum/leaderboard.twig b/templates/forum/leaderboard.twig
index 2a7642aa..4c73f75e 100644
--- a/templates/forum/leaderboard.twig
+++ b/templates/forum/leaderboard.twig
@@ -1,6 +1,5 @@
 {% extends 'forum/master.twig' %}
 {% from 'macros.twig' import avatar %}
-{% from 'forum/macros.twig' import forum_header %}
 
 {% set title = 'Forum Leaderboard » ' ~ leaderboard_name %}
 {% set canonical_url = url('forum-leaderboard', {
@@ -9,18 +8,23 @@
 }) %}
 
 {% block content %}
-    {{ forum_header(title, [], false, canonical_url, [
-        {
-            'html': '<i class="fab fa-markdown fa-fw"></i> Markdown',
-            'url': url('forum-leaderboard', {'id': leaderboard_id, 'mode': 'markdown'}),
-            'display': leaderboard_mode != 'markdown',
-        },
-        {
-            'html': '<i class="fas fa-table fa-fw"></i> Table',
-            'url': url('forum-leaderboard', {'id': leaderboard_id}),
-            'display': leaderboard_mode == 'markdown',
-        },
-    ]) }}
+    <div class="container forum__header">
+        <a class="forum__header__title" href="{{ canonical_url }}">
+            {{ title }}
+        </a>
+
+        <div class="forum__header__actions">
+            {% if leaderboard_mode == 'markdown' %}
+                <a class="forum__header__action" href="{{ url('forum-leaderboard', {'id': leaderboard_id}) }}">
+                    <i class="fas fa-table fa-fw"></i> Table
+                </a>
+            {% else %}
+                <a class="forum__header__action" href="{{ url('forum-leaderboard', {'id': leaderboard_id, 'mode': 'markdown'}) }}">
+                    <i class="fas fa-markdown fa-fw"></i> Markdown
+                </a>
+            {% endif %}
+        </div>
+    </div>
 
     <div class="container forum__leaderboard__categories">
         {% for id, name in leaderboard_categories %}
diff --git a/templates/forum/macros.twig b/templates/forum/macros.twig
index adca2fb9..25d05cdf 100644
--- a/templates/forum/macros.twig
+++ b/templates/forum/macros.twig
@@ -1,4 +1,44 @@
 {% macro forum_category_listing(forums, title, colour, id, icon) %}
+    {% if forums is iterable %}
+        {% from _self import forum_category_listing_old %}
+        {{ forum_category_listing_old(forums, title, colour, id, icon) }}
+    {% else %}
+        {% from _self import forum_category_listing_new %}
+        {{ forum_category_listing_new(forums, title, colour) }}
+    {% endif %}
+{% endmacro %}
+
+{% macro forum_category_listing_new(category, user, title) %}
+    {% from _self import forum_category_entry %}
+    {% from 'macros.twig' import container_title %}
+
+    {% if category.canView(user) %}
+        {% set children = category.children(user) %}
+
+        {% if children is not empty or category.isCategoryForum %}
+        {% set icon = title is not empty ? 'fas fa-folder fa-fw' : category.icon %}
+        <div class="container forum__categories"
+            {% if not category.colour.inherit %}style="--accent-colour: {{ category.colour }}"{% endif %}
+            {% if not category.isRoot %}id="{{ category.id }}"{% endif %}>
+            {{ container_title('<span class="' ~ icon ~ '"></span> ' ~ title|default(category.name)) }}
+
+            {% if children is empty %}
+                <div class="forum__categories__empty">
+                    This category is empty.
+                </div>
+            {% else %}
+                <div class="forum__categories__list">
+                    {% for category in children %}
+                        {{ forum_category_entry(category, user) }}
+                    {% endfor %}
+                </div>
+            {% endif %}
+        </div>
+        {% endif %}
+    {% endif %}
+{% endmacro %}
+
+{% macro forum_category_listing_old(forums, title, colour, id, icon) %}
     {% from _self import forum_category_entry %}
     {% from 'macros.twig' import container_title %}
 
@@ -21,32 +61,29 @@
     </div>
 {% endmacro %}
 
-{% macro forum_header(title, breadcrumbs, omit_last_breadcrumb, title_url, actions) %}
+{% macro forum_header(info, is_top, title_url, actions, is_topic, title) %}
     <div class="container forum__header">
-        {% if breadcrumbs is iterable and breadcrumbs|length > 0 %}
-            <div class="forum__header__breadcrumbs">
-                {% for name, url in breadcrumbs %}
-                    {% if url != breadcrumbs|first %}
-                        <div class="forum__header__breadcrumb__separator">
-                            <i class="fas fa-chevron-right"></i>
-                        </div>
-                    {% endif %}
+        {% set parents = info.parentTree %}
+        <div class="forum__header__breadcrumbs">
+            {% for parent in parents %}
+                <a href="{{ parent.url }}" class="forum__header__breadcrumb">{{ parent.name }}</a>
+                <div class="forum__header__breadcrumb__separator">
+                    <i class="fas fa-chevron-right"></i>
+                </div>
+            {% endfor %}
+            {% if not is_top or is_topic %}
+                <a href="{{ info.url }}" class="forum__header__breadcrumb">{{ info.name }}</a>
+            {% endif %}
+        </div>
 
-                    {% if not (omit_last_breadcrumb|default(false) and url == breadcrumbs|last) %}
-                        <a href="{{ url }}" class="forum__header__breadcrumb">{{ name }}</a>
-                    {% endif %}
-                {% endfor %}
-            </div>
-        {% endif %}
-
-        {% if title|length > 0 %}
+        {% if is_top %}
             {% if title_url|length > 0 %}
                 <a class="forum__header__title" href="{{ title_url }}">
-                    {{ title }}
+                    {{ title|default(info.name) }}
                 </a>
             {% else %}
                 <div class="forum__header__title forum__header__title--fill">
-                    {{ title }}
+                    {{ title|default(info.name) }}
                 </div>
             {% endif %}
         {% endif %}
@@ -65,49 +102,145 @@
     </div>
 {% endmacro %}
 
-{% macro forum_category_tools(info, perms, pagination_info) %}
-    {% from 'macros.twig' import pagination %}
-
-    {% set is_locked = info.forum_archived != 0 %}
-    {% set can_topic = not is_locked and perms|perms_check(constant('MSZ_FORUM_PERM_CREATE_TOPIC')) %}
-    {% set pag = pagination(pagination_info, url('forum-category'), null, {'f': info.forum_id}) %}
+{% macro forum_category_tools(category, perms, pagination_info) %}
+    {% set can_topic = not category.isArchived and perms|perms_check(constant('MSZ_FORUM_PERM_CREATE_TOPIC')) %}
+    {% set pag = pagination_info.render(url('forum-category', {'forum': category.id})) %}
 
     {% if can_topic or pag|trim|length > 0 %}
         <div class="container forum__actions">
             <div class="forum__actions__buttons">
                 {% if can_topic %}
-                    <a href="{{ url('forum-topic-new', {'forum': info.forum_id}) }}" class="input__button forum__actions__button">{{ info.forum_type == constant('MSZ_FORUM_TYPE_FEATURE') ? 'New Request' : 'New Topic' }}</a>
+                    <a href="{{ url('forum-topic-new', {'forum': category.id}) }}" class="input__button forum__actions__button">{{ category.canHavePriorityVotes ? 'New Request' : 'New Topic' }}</a>
                 {% endif %}
             </div>
 
             <div class="forum__actions__pagination">
-                {{ pag }}
+                {{ pag|raw }}
             </div>
         </div>
     {% endif %}
 {% endmacro %}
 
 {% macro forum_topic_tools(info, pagination_info, can_reply) %}
-    {% from 'macros.twig' import pagination %}
+    {% set pag = pagination_info.render('forum-topic', {'t': info.topic_id|default(info.id)}, 'page') %}
 
-    {% set pag = pagination(pagination_info, url('forum-topic'), null, {'t': info.topic_id}, 'page') %}
-
-    {% if can_reply or pag|trim|length > 0 %}
+    {% if can_reply or pag|length > 0 %}
         <div class="container forum__actions">
             <div class="forum__actions__buttons">
                 {% if can_reply %}
-                    <a href="{{ url('forum-reply-new', {'topic': info.topic_id}) }}" class="input__button">Reply</a>
+                    <a href="{{ url('forum-reply-new', {'topic': info.topic_id|default(info.id)}) }}" class="input__button">Reply</a>
                 {% endif %}
             </div>
 
             <div class="forum__actions__pagination">
-                {{ pag }}
+                {{ pag|raw }}
             </div>
         </div>
     {% endif %}
 {% endmacro %}
 
 {% macro forum_category_entry(forum, forum_unread, forum_icon) %}
+    {% if forum.forum_id is defined %}
+        {% from _self import forum_category_entry_old %}
+        {{ forum_category_entry_old(forum, forum_unread, forum_icon) }}
+    {% else %}
+        {% from _self import forum_category_entry_new %}
+        {{ forum_category_entry_new(forum, forum_unread) }}
+    {% endif %}
+{% endmacro %}
+
+{% macro forum_category_entry_new(category, user) %}
+    {% from 'macros.twig' import avatar %}
+
+    <div class="forum__category">
+        <a href="{{ url('forum-category', {'forum': category.id}) }}" class="forum__category__link"></a>
+
+        <div class="forum__category__container">
+            <div class="forum__category__icon forum__category__icon--{{ user is null or category.hasRead(user) ? '' : 'un' }}read">
+                <span class="{{ category.icon }}"></span>
+            </div>
+
+            <div class="forum__category__details">
+                <div class="forum__category__title">
+                    {{ category.name }}
+                </div>
+
+                {% if category.hasDescription %}
+                <div class="forum__category__description">
+                    {{ category.parsedDescription|raw }}
+                </div>
+                {% endif %}
+
+                {% if category.children is not empty %}
+                    <div class="forum__category__subforums">
+                        {% for child in category.children %}
+                        {% if child.canView(user) %}
+                            <a href="{{ url('forum-category', {'forum': child.id}) }}"
+                                class="forum__category__subforum{% if user is not null and not child.hasRead(user) %} forum__category__subforum--unread{% endif %}">
+                                {{ child.name }}
+                            </a>
+                        {% endif %}
+                        {% endfor %}
+                    </div>
+                {% endif %}
+            </div>
+
+            {% if category.isLink %}
+                {% if category.shouldCountLinkClicks %}
+                    <div class="forum__category__stats">
+                        <div class="forum__category__stat" title="Clicks">{{ category.linkClicks|number_format }}</div>
+                    </div>
+                {% endif %}
+            {% elseif category.canHaveChildren %}
+                <div class="forum__category__stats">
+                    <div class="forum__category__stat" title="Topics">{{ category.topicCount|number_format }}</div>
+                    <div class="forum__category__stat" title="Posts">{{ category.postCount|number_format }}</div>
+                </div>
+            {% endif %}
+
+            {% if category.canHaveTopics or category.shouldCountLinkClicks %}
+                <div class="forum__category__activity{% if category.shouldCountLinkClicks %} forum__category__activity--empty{% endif %}">
+                    {% if not category.isLink %}
+                        {% set topic = category.latestTopic(user) %}
+                        {% set post = topic.lastPost|default(null) %}
+                        {% if topic is empty or topic.lastPost is empty %}
+                            <div class="forum__category__activity__none">
+                                There are no posts in this forum yet.
+                            </div>
+                        {% else %}
+                            <div class="forum__category__activity__details">
+                                <a class="forum__category__activity__post"
+                                    href="{{ url('forum-post', {'post': post.id, 'post_fragment': 'p' ~ post.id}) }}">
+                                    {{ topic.title }}
+                                </a>
+
+                                <div class="forum__category__activity__info">
+                                    <time datetime="{{ post.createdTime|date('c') }}"
+                                        title="{{ post.createdTime|date('r') }}">{{ post.createdTime|time_diff }}</time>
+                                    {% if post.hasUser %}
+                                        by
+                                        <a href="{{ url('user-profile', {'user': post.user.id}) }}" class="forum__category__username"
+                                            style="--user-colour: {{ post.user.colour }}">
+                                            {{ post.user.username }}
+                                        </a>
+                                    {% endif %}
+                                </div>
+                            </div>
+
+                            {% if post.hasUser %}
+                                <a href="{{ url('user-profile', {'user': post.user.id}) }}" class="avatar forum__category__avatar">
+                                    {{ avatar(post.user.id, 40, post.user.username) }}
+                                </a>
+                            {% endif %}
+                        {% endif %}
+                    {% endif %}
+                </div>
+            {% endif %}
+        </div>
+    </div>
+{% endmacro %}
+
+{% macro forum_category_entry_old(forum, forum_unread, forum_icon) %}
     {% from 'macros.twig' import avatar %}
     {% set forum_unread = forum_unread|default(forum.forum_unread|default(false)) ? 'unread' : 'read' %}
 
@@ -116,12 +249,12 @@
             {% set forum_icon = forum.forum_icon %}
         {% elseif forum.forum_archived is defined and forum.forum_archived %}
             {% set forum_icon = 'fas fa-archive fa-fw' %}
-        {% elseif forum.forum_type is defined and forum.forum_type != constant('MSZ_FORUM_TYPE_DISCUSSION') %}
-            {% if forum.forum_type == constant('MSZ_FORUM_TYPE_FEATURE') %}
+        {% elseif forum.forum_type is defined and forum.forum_type != constant('\\Misuzu\\Forum\\ForumCategory::TYPE_DISCUSSION') %}
+            {% if forum.forum_type == constant('\\Misuzu\\Forum\\ForumCategory::TYPE_FEATURE') %}
                 {% set forum_icon = 'fas fa-star fa-fw' %}
-            {% elseif forum.forum_type == constant('MSZ_FORUM_TYPE_LINK') %}
+            {% elseif forum.forum_type == constant('\\Misuzu\\Forum\\ForumCategory::TYPE_LINK') %}
                 {% set forum_icon = 'fas fa-link fa-fw' %}
-            {% elseif forum.forum_type == constant('MSZ_FORUM_TYPE_CATEGORY') %}
+            {% elseif forum.forum_type == constant('\\Misuzu\\Forum\\ForumCategory::TYPE_CATEGORY') %}
                 {% set forum_icon = 'fas fa-folder fa-fw' %}
             {% endif %}
         {% else %}
@@ -158,22 +291,22 @@
                 {% endif %}
             </div>
 
-            {% if forum.forum_type == constant('MSZ_FORUM_TYPE_LINK') %}
+            {% if forum.forum_type == constant('\\Misuzu\\Forum\\ForumCategory::TYPE_LINK') %}
                 {% if forum.forum_link_clicks is not null %}
                     <div class="forum__category__stats">
                         <div class="forum__category__stat" title="Clicks">{{ forum.forum_link_clicks|number_format }}</div>
                     </div>
                 {% endif %}
-            {% elseif forum_may_have_children(forum.forum_type) %}
+            {% elseif forum.forum_type in constant('\\Misuzu\\Forum\\ForumCategory::HAS_CHILDREN') %}
                 <div class="forum__category__stats">
                     <div class="forum__category__stat" title="Topics">{{ forum.forum_count_topics|number_format }}</div>
                     <div class="forum__category__stat" title="Posts">{{ forum.forum_count_posts|number_format }}</div>
                 </div>
             {% endif %}
 
-            {% if forum_may_have_topics(forum.forum_type) or forum.forum_link_clicks is not null %}
+            {% if forum.forum_type in constant('\\Misuzu\\Forum\\ForumCategory::HAS_TOPICS') or forum.forum_link_clicks is not null %}
                 <div class="forum__category__activity{% if forum.forum_link_clicks is not null %} forum__category__activity--empty{% endif %}">
-                    {% if forum.forum_type != constant('MSZ_FORUM_TYPE_LINK') %}
+                    {% if forum.forum_type != constant('\\Misuzu\\Forum\\ForumCategory::TYPE_LINK') %}
                         {% if forum.recent_topic_id is not defined %}
                             <div class="forum__category__activity__none">
                                 There are no posts in this forum yet.
@@ -212,7 +345,7 @@
 {% endmacro %}
 
 {% macro forum_topic_locked(locked, archived) %}
-    {% if locked is not null or archived %}
+    {% if locked|default(0) > 0 is not null or archived %}
         <div class="container forum__status">
             <div class="forum__status__icon">
                 <div class="forum__status__icon__background"></div>
@@ -223,16 +356,14 @@
                     This topic has been <span class="forum__status__emphasis">archived</span>.
                 {% else %}
                     This topic was locked
-                    <time class="forum__status__emphasis"
-                        datetime="{{ locked|date('c') }}"
-                        title="{{ locked|date('r') }}">{{ locked|time_diff }}</time>.
+                    <time class="forum__status__emphasis" datetime="{{ locked|date('c') }}" title="{{ locked|date('r') }}">{{ locked|time_diff }}</time>.
                 {% endif %}
             </div>
         </div>
     {% endif %}
 {% endmacro %}
 
-{% macro forum_topic_listing(topics, title) %}
+{% macro forum_topic_listing(topics, title, user) %}
     {% from _self import forum_topic_entry %}
     {% from 'macros.twig' import container_title %}
 
@@ -242,7 +373,7 @@
         <div class="forum__topics__list">
             {% if topics|length > 0 %}
                 {% for topic in topics %}
-                    {{ forum_topic_entry(topic) }}
+                    {{ forum_topic_entry(topic, user) }}
                 {% endfor %}
             {% else %}
                 <div class="forum__topics__empty">
@@ -254,18 +385,122 @@
 {% endmacro %}
 
 {% macro forum_topic_entry(topic, topic_icon, topic_unread) %}
+    {% if topic.getId is defined %}
+        {% from _self import forum_topic_entry_new %}
+        {{ forum_topic_entry_new(topic, topic_icon) }}
+    {% else %}
+        {% from _self import forum_topic_entry_old %}
+        {{ forum_topic_entry_old(topic, topic_icon, topic_unread) }}
+    {% endif %}
+{% endmacro %}
+
+{% macro forum_topic_entry_new(topic, user) %}
+    {% from 'macros.twig' import avatar %}
+
+    <div class="forum__topic{% if topic.deleted %} forum__topic--deleted{% elseif topic.locked and not topic.important %} forum__topic--locked{% endif %}">
+        <a href="{{ url('forum-topic', {'topic': topic.id}) }}" class="forum__topic__link"></a>
+
+        <div class="forum__topic__container">
+            <div class="forum__topic__icon forum__topic__icon--{{ user is null or topic.hasRead(user) ? '' : 'un' }}read{% if topic.hasPriorityVoting %} forum__topic__icon--wide{% endif %}">
+                <i class="{{ topic.icon(user) }}{% if topic.hasPriorityVoting %} forum__topic__icon--faded{% endif %}"></i>
+
+                {% if topic.hasPriorityVoting %}
+                    <div class="forum__topic__icon__priority">{{ topic.priority|number_format }}</div>
+                {% endif %}
+
+                {% if topic.hasParticipated(user) %}
+                    <div class="forum__topic__icon__participated" title="You have posted in this topic"></div>
+                {% endif %}
+            </div>
+
+            <div class="forum__topic__details">
+                <div class="forum__topic__title">
+                    <span class="forum__topic__title__inner">
+                        {{ topic.title }}
+                    </span>
+                </div>
+
+                <div class="forum__topic__info">
+                    {% if topic.user is not empty %}
+                        by <a href="{{ url('user-profile', {'user': topic.user.id}) }}"
+                            class="forum__topic__username"
+                            style="--user-colour: {{ topic.user.colour }}">{{ topic.user.username }}</a>,
+
+                    {% endif %}
+                    <time datetime="{{ topic.createdTime|date('c') }}" title="{{ topic.createdTime|date('r') }}">{{ topic.createdTime|time_diff }}</time>
+                </div>
+
+                {% set pages = topic.pageCount %}
+
+                {% if pages > 1 %}
+                    <div class="forum__topic__pagination">
+                        {% for i in 1..pages|clamp(0, 3) %}
+                            <a href="{{ url('forum-topic', {'topic': topic.id, 'page': i}) }}" class="forum__topic__pagination__item">
+                                {{ i }}
+                            </a>
+                        {% endfor %}
+                        {% if pages > 3 %}
+                            {% if pages > 6 %}
+                                <div class="forum__topic__pagination__separator">
+                                    <i class="fas fa-ellipsis-h"></i>
+                                </div>
+                            {% endif %}
+
+                            {% for i in (pages - 2)|clamp(4, pages)..pages %}
+                                <a href="{{ url('forum-topic', {'topic': topic.id, 'page': i}) }}" class="forum__topic__pagination__item">
+                                    {{ i }}
+                                </a>
+                            {% endfor %}
+                        {% endif %}
+                    </div>
+                {% endif %}
+            </div>
+
+            <div class="forum__topic__stats">
+                <div class="forum__topic__stat" title="Posts">{{ topic.postCount|number_format }}</div>
+                <div class="forum__topic__stat" title="Views">{{ topic.viewCount|number_format }}</div>
+            </div>
+
+            <div class="forum__topic__activity">
+                {% set post = topic.lastPost %}
+                {% if post is not null %}
+                <div class="forum__topic__activity__details">
+                    {% if post.user is not empty %}
+                        <a href="{{ url('user-profile', {'user': post.user.id}) }}" class="forum__topic__username"
+                            style="--user-colour: {{ post.user.colour }}">{{ post.user.username }}</a>
+                    {% endif %}
+
+                    <a class="forum__topic__activity__post"
+                        href="{{ url('forum-post', {'post': post.id, 'post_fragment': 'p' ~ post.id}) }}">
+                        <time datetime="{{ post.createdTime|date('c') }}"
+                            title="{{ post.createdTime|date('r') }}">{{ post.createdTime|time_diff }}</time>
+                    </a>
+                </div>
+
+                {% if post.user is not empty %}
+                    <a href="{{ url('user-profile', {'user': post.user.id}) }}" class="forum__topic__avatar">
+                        {{ avatar(post.user.id, 30, post.user.username) }}
+                    </a>
+                {% endif %}
+                {% endif %}
+            </div>
+        </div>
+    </div>
+{% endmacro %}
+
+{% macro forum_topic_entry_old(topic, topic_icon, topic_unread) %}
     {% from 'macros.twig' import avatar %}
     {% set topic_unread = topic_unread|default(topic.topic_unread|default(false)) %}
-    {% set topic_important = topic.topic_type == constant('MSZ_TOPIC_TYPE_STICKY') or topic.topic_type == constant('MSZ_TOPIC_TYPE_ANNOUNCEMENT') or topic.topic_type == constant('MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT') %}
-    {% set has_priority_voting = forum_has_priority_voting(topic.forum_type) %}
+    {% set topic_important = topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::TYPE_STICKY') or topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::TYPE_ANNOUNCEMENT') or topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::TYPE_GLOBAL_ANNOUNCEMENT') %}
+    {% set has_priority_voting = topic.forum_type in constant('\\Misuzu\\Forum\\ForumCategory::HAS_PRIORITY_VOTES') %}
 
     {% if topic_icon is null %}
         {% if topic.topic_deleted is defined and topic.topic_deleted is not null %}
             {% set topic_icon = 'fas fa-trash-alt' %}
-        {% elseif topic.topic_type is defined and topic.topic_type != constant('MSZ_TOPIC_TYPE_DISCUSSION') %}
-            {% if topic.topic_type == constant('MSZ_TOPIC_TYPE_ANNOUNCEMENT') or topic.topic_type == constant('MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT') %}
+        {% elseif topic.topic_type is defined and topic.topic_type != constant('\\Misuzu\\Forum\\ForumTopic::DISCUSSION') %}
+            {% if topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::TYPE_ANNOUNCEMENT') or topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::GLOBAL_ANNOUNCEMENT') %}
                 {% set topic_icon = 'fas fa-bullhorn' %}
-            {% elseif topic.topic_type == constant('MSZ_TOPIC_TYPE_STICKY') %}
+            {% elseif topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::STICKY') %}
                 {% set topic_icon = 'fas fa-thumbtack' %}
             {% endif %}
         {% elseif topic.topic_locked is defined and topic.topic_locked is not null %}
@@ -373,6 +608,114 @@
 {% endmacro %}
 
 {% macro forum_post_entry(post, user_id, perms) %}
+    {% if post.getId is defined %}
+        {% from _self import forum_post_entry_new %}
+        {{ forum_post_entry_new(post, user_id, perms) }}
+    {% else %}
+        {% from _self import forum_post_entry_old %}
+        {{ forum_post_entry_old(post, user_id, perms) }}
+    {% endif %}
+{% endmacro %}
+
+{% macro forum_post_entry_new(post, user, perms) %}
+    {% from 'macros.twig' import avatar %}
+    {% set can_post = perms|perms_check(constant('MSZ_FORUM_PERM_CREATE_POST')) %}
+    {% set can_edit = perms|perms_check(constant('MSZ_FORUM_PERM_EDIT_ANY_POST')) or (
+        perms|perms_check(constant('MSZ_FORUM_PERM_EDIT_POST'))
+        and post.canBeEdited(user)
+    ) %}
+    {% set can_delete = not post.isOpeningPost and (
+        perms|perms_check(constant('MSZ_FORUM_PERM_DELETE_ANY_POST')) or (
+            perms|perms_check(constant('MSZ_FORUM_PERM_DELETE_POST'))
+            and post.canBeDeleted(user)
+        )
+    ) %}
+
+    <div class="container forum__post{% if post.deleted %} forum__post--deleted{% endif %}" id="p{{ post.id }}" style="{% if post.hasUser %}--accent-colour: {{ post.user.colour }}{% endif %}">
+        <div class="forum__post__info">
+            <div class="forum__post__info__background"></div>
+            <div class="forum__post__info__content">
+                {% if post.hasUser %}
+                    <a class="forum__post__avatar" href="{{ url('user-profile', {'user': post.user.id}) }}">
+                        {{ avatar(post.user.id, 120, post.user.username) }}
+                    </a>
+
+                    <a class="forum__post__username" href="{{ url('user-profile', {'user': post.user.id}) }}">{{ post.user.username }}</a>
+
+                    {% if post.user.hasTitle %}
+                        <div class="forum__post__usertitle">{{ post.user.title }}</div>
+                    {% endif %}
+
+                    <div class="forum__post__icons">
+                        <div class="flag flag--{{ post.user.country|lower }}" title="{{ post.user.countryName }}"></div>
+                        <div class="forum__post__posts-count">{{ post.user.forumPostCount|number_format }} posts</div>
+                    </div>
+
+                    {% if post.isTopicAuthor %}
+                        <div class="forum__post__badge forum__post__badge--original-poster">
+                            <div class="forum__post__badge__desktop">Original Poster</div>
+                            <div class="forum__post__badge__mobile">OP</div>
+                        </div>
+                    {% endif %}
+
+                    <div class="forum__post__joined">
+                        joined <time datetime="{{ post.user.createdTime|date('c') }}" title="{{ post.user.createdTime|date('r') }}">{{ post.user.createdTime|time_diff }}</time>
+                    </div>
+                {% else %}
+                    <div class="forum__post__username">Deleted User</div>
+                {% endif %}
+            </div>
+        </div>
+
+        <div class="forum__post__content">
+            {% set post_link = url(post.isOpeningPost ? 'forum-topic' : 'forum-post', {'topic': post.topic.id, 'post': post.id, 'post_fragment': 'p%d'|format(post.id)}) %}
+
+            <div class="forum__post__details">
+                <a class="forum__post__datetime" href="{{ post_link }}">
+                    <time datetime="{{ post.createdTime|date('c') }}" title="{{ post.createdTime|date('r') }}">{{ post.createdTime|time_diff }}</time>
+                    {% if post.isEdited %}
+                        (edited <time datetime="{{ post.editedTime|date('c') }}" title="{{ post.editedTime|date('r') }}">{{ post.editedTime|time_diff }}</time>)
+                    {% endif %}
+                </a>
+
+                <a class="forum__post__id" href="{{ post_link }}">
+                    #{{ post.id }}
+                </a>
+            </div>
+
+            <div class="forum__post__text {{ post.bodyClasses }}">
+                {{ post.parsedBody|raw }}
+            </div>
+
+            {% if can_post or can_edit or can_delete %}
+                <div class="forum__post__actions">
+                    {% if post.deleted %}
+                        <a href="{{ url('forum-post-restore', {'post': post.id}) }}" class="forum__post__action forum__post__action--restore"><i class="fas fa-magic fa-fw"></i> Restore</a>
+                        <a href="{{ url('forum-post-nuke', {'post': post.id}) }}" class="forum__post__action forum__post__action--nuke"><i class="fas fa-radiation-alt fa-fw"></i> Permanently Delete</a>
+                    {% else %}
+                        {# if can_post %}
+                            <a href="{{ url('forum-post-quote', {'post': post.id}) }}" class="forum__post__action forum__post__action--quote"><i class="fas fa-quote-left fa-fw"></i> Quote</a>
+                        {% endif #}
+                        {% if can_edit %}
+                            <a href="{{ url('forum-post-edit', {'post': post.id}) }}" class="forum__post__action forum__post__action--edit"><i class="fas fa-edit fa-fw"></i> Edit</a>
+                        {% endif %}
+                        {% if can_delete %}
+                            <a href="{{ url('forum-post-delete', {'post': post.id}) }}" class="forum__post__action forum__post__action--delete"><i class="far fa-trash-alt fa-fw"></i> Delete</a>
+                        {% endif %}
+                    {% endif %}
+                </div>
+            {% endif %}
+
+            {% if post.shouldDisplaySignature and post.user.hasForumSignature %}
+                <div class="forum__post__signature {{ post.user.forumSignatureClasses }}">
+                    {{ post.user.forumSignatureParsed|raw }}
+                </div>
+            {% endif %}
+        </div>
+    </div>
+{% endmacro %}
+
+{% macro forum_post_entry_old(post, user_id, perms) %}
     {% from 'macros.twig' import avatar %}
     {% set is_deleted   = post.post_deleted is not null %}
     {% set can_post     = perms|perms_check(constant('MSZ_FORUM_PERM_CREATE_POST')) %}
@@ -384,7 +727,7 @@
         perms|perms_check(constant('MSZ_FORUM_PERM_DELETE_ANY_POST')) or (
             user_id == post.poster_id
             and perms|perms_check(constant('MSZ_FORUM_PERM_DELETE_POST'))
-            and post.post_created|date('U') > ''|date('U') - constant('MSZ_FORUM_POST_DELETE_LIMIT')
+            and post.post_created|date('U') > ''|date('U') - constant('\\Misuzu\\Forum\\ForumPost::DELETE_AGE_LIMIT')
         )
     ) %}
 
@@ -472,116 +815,111 @@
     </div>
 {% endmacro %}
 
-{% macro forum_poll(poll, options, user_answers, topic_id, can_vote, preview_results) %}
+{% macro forum_poll(poll, user) %}
     {% from '_layout/input.twig' import input_csrf, input_hidden, input_checkbox, input_checkbox_raw %}
-    {% set user_answers = user_answers is empty or user_answers is not iterable ? [] : user_answers %}
-    {% set user_answered = user_answers|length > 0 %}
-    {% set results_available = preview_results or user_answered or poll.poll_expired or poll.poll_preview_results %}
-    {% set options_available = not poll.poll_expired and (poll.poll_change_vote or not user_answered) %}
-    {% set display_results = user_answered or poll.poll_expired %}
 
-    {% if options is iterable and options|length > 0 %}
-        <div class="forum__poll">
-            {% if results_available %}
-                {% if options_available %}
-                    {{ input_checkbox_raw('', display_results, 'forum__poll__toggle', '', false, {'id':'forum-poll-toggle'}) }}
+    {% set can_vote = poll.canVoteOnPoll(user) %}
+    {% set results_available = poll.hasVoted(user) or poll.hasExpired or poll.canPreviewResults %}
+    {% set options_available = not poll.hasExpired and (poll.canChangeVote or not poll.hasVoted(user)) %}
+    {% set display_results = poll.hasVoted(user) or poll.hasExpired %}
+
+    <div class="forum__poll">
+        {% if results_available %}
+            {% if options_available %}
+                {{ input_checkbox_raw('', display_results, 'forum__poll__toggle', '', false, {'id':'forum-poll-toggle'}) }}
+            {% endif %}
+
+            <div class="container forum__poll__container forum__poll__container--results">
+                <div class="forum__poll__results">
+                    {% for option in poll.options %}
+                        <div class="forum__poll__result{% if option.hasVotedFor(user) %} forum__poll__result--voted{% endif %}">
+                            <div class="forum__poll__result__background" style="width: {{ option.percentage * 100 }}%"></div>
+                            <div class="forum__poll__result__container">
+                                <div class="forum__poll__result__text">{{ option.text }}</div>
+                                <div class="forum__poll__result__votes">{{ option.votes|number_format }}</div>
+                                <div class="forum__poll__result__percent">{{ (option.percentage * 100)|number_format(2) }}%</div>
+                            </div>
+                        </div>
+                    {% endfor %}
+                </div>
+
+                <div class="forum__poll__remaining">
+                    This poll got <span class="forum__poll__remaining__num">{{ poll.votes|number_format }} vote{{ poll.votes == 1 ? '' : 's' }}</span>
+                </div>
+
+                {% if poll.canExpire %}
+                    <div class="forum__poll__expires">
+                        Polling {{ poll.hasExpired ? 'closed' : 'will close' }} <time class="forum__poll__expires__datetime" datetime="{{ poll.expiresTime|date('c') }}" title="{{ poll.expiresTime|date('r') }}">{{ poll.expiresTime|time_diff }}</time>.
+                    </div>
                 {% endif %}
 
-                <div class="container forum__poll__container forum__poll__container--results">
-                    <div class="forum__poll__results">
-                        {% for option in options %}
-                            {% set percent = poll.poll_votes < 1 ? 0 : (option.option_votes / poll.poll_votes) * 100 %}
-
-                            <div class="forum__poll__result{% if option.option_id in user_answers %} forum__poll__result--voted{% endif %}">
-                                <div class="forum__poll__result__background" style="width: {{ percent }}%">
-                                </div>
-                                <div class="forum__poll__result__container">
-                                    <div class="forum__poll__result__text">{{ option.option_text }}</div>
-                                    <div class="forum__poll__result__votes">{{ option.option_votes|number_format }}</div>
-                                    <div class="forum__poll__result__percent">{{ percent|number_format(2) }}%</div>
-                                </div>
-                            </div>
-                        {% endfor %}
+                {% if options_available %}
+                    <div class="forum__poll__buttons">
+                        <label class="input__button forum__poll__button" for="forum-poll-toggle">Vote</label>
                     </div>
+                {% endif %}
+            </div>
+        {% endif %}
 
-                    <div class="forum__poll__remaining">
-                        This poll got <span class="forum__poll__remaining__num">{{ poll.poll_votes|number_format }} vote{{ poll.poll_votes == 1 ? '' : 's' }}</span>
+        {% if options_available %}
+            <form method="post" action="{{ url('forum-poll-vote') }}" class="container forum__poll__container forum__poll__container--poll js-forum-poll"
+                data-poll-id="{{ poll.id }}" data-poll-max-votes="{{ poll.maxVotes }}">
+                {{ input_csrf() }}
+                {{ input_hidden('poll[id]', poll.id) }}
+
+                <div class="forum__poll__options">
+                    {% for option in poll.options %}
+                        {{ input_checkbox(
+                            'poll[answers][]',
+                            option.text, option.hasVotedFor(user), 'forum__poll__option',
+                            option.id, poll.maxVotes <= 1,
+                            null, not can_vote
+                        ) }}
+                    {% endfor %}
+                </div>
+
+                {% if can_vote and poll.maxVotes > 1 %}
+                    <div class="forum__poll__remaining js-forum-poll-remaining">
+                        You have <span class="forum__poll__remaining__num">
+                            <span class="js-forum-poll-remaining-count">{{ poll.maxVotes }}</span> vote<span class="js-forum-poll-remaining-plural">s</span>
+                        </span> remaining.
                     </div>
+                {% endif %}
 
-                    {% if poll.poll_expires is not null %}
-                        <div class="forum__poll__expires">
-                            Polling {{ poll.poll_expired ? 'closed' : 'will close' }} <time class="forum__poll__expires__datetime" datetime="{{ poll.poll_expires|date('c') }}" title="{{ poll.poll_expires|date('r') }}">{{ poll.poll_expires|time_diff }}</time>.
-                        </div>
+                {% if poll.canExpire %}
+                    <div class="forum__poll__expires">
+                        Polling {{ poll.hasExpired ? 'closed' : 'will close' }} <time class="forum__poll__expires__datetime" datetime="{{ poll.expiresTime|date('c') }}" title="{{ poll.expiresTime|date('r') }}">{{ poll.expiresTime|time_diff }}</time>.
+                    </div>
+                {% endif %}
+
+                <div class="forum__poll__buttons">
+                    {% if can_vote %}
+                        <button class="input__button forum__poll__button">Vote</button>
                     {% endif %}
-
-                    {% if options_available %}
-                        <div class="forum__poll__buttons">
-                            <label class="input__button forum__poll__button" for="forum-poll-toggle">Vote</label>
-                        </div>
+                    {% if results_available %}
+                        <label class="input__button forum__poll__button" for="forum-poll-toggle">Results</label>
                     {% endif %}
                 </div>
-            {% endif %}
-
-            {% if options_available %}
-                <form method="post" action="{{ url('forum-poll-vote') }}" class="container forum__poll__container forum__poll__container--poll js-forum-poll"
-                    data-poll-id="{{ poll.poll_id }}" data-poll-max-votes="{{ poll.poll_max_votes }}">
-                    {{ input_csrf() }}
-                    {{ input_hidden('poll[id]', poll.poll_id) }}
-
-                    <div class="forum__poll__options">
-                        {% for option in options %}
-                            {{ input_checkbox(
-                                'poll[answers][]',
-                                option.option_text, option.option_id in user_answers, 'forum__poll__option',
-                                option.option_id, poll.poll_max_votes <= 1,
-                                null, not can_vote
-                            ) }}
-                        {% endfor %}
-                    </div>
-
-                    {% if can_vote and poll.poll_max_votes > 1 %}
-                        <div class="forum__poll__remaining js-forum-poll-remaining">
-                            You have <span class="forum__poll__remaining__num">
-                                <span class="js-forum-poll-remaining-count">{{ poll.poll_max_votes }}</span> vote<span class="js-forum-poll-remaining-plural">s</span>
-                            </span> remaining.
-                        </div>
-                    {% endif %}
-
-                    {% if poll.poll_expires is not null %}
-                        <div class="forum__poll__expires">
-                            Polling {{ poll.poll_expired ? 'closed' : 'will close' }} <time class="forum__poll__expires__datetime" datetime="{{ poll.poll_expires|date('c') }}" title="{{ poll.poll_expires|date('r') }}">{{ poll.poll_expires|time_diff }}</time>.
-                        </div>
-                    {% endif %}
-
-                    <div class="forum__poll__buttons">
-                        {% if can_vote %}
-                            <button class="input__button forum__poll__button">Vote</button>
-                        {% endif %}
-                        {% if results_available %}
-                            <label class="input__button forum__poll__button" for="forum-poll-toggle">Results</label>
-                        {% endif %}
-                    </div>
-                </form>
-            {% endif %}
-        </div>
-    {% endif %}
+            </form>
+        {% endif %}
+    </div>
 {% endmacro %}
 
-{% macro forum_priority_votes(topic, votes, can_vote) %}
+{% macro forum_priority_votes(topic, user) %}
     <div class="container forum__priority">
         <div class="forum__priority__votes">
-            {% for vote in votes %}
-                <div title="{{ vote.username }} ({{ vote.topic_priority|number_format }})" class="forum__priority__vote" style="{{ vote.user_colour|html_colour }}">
-                    {% for i in 1..vote.topic_priority %}
+            {% for vote in topic.priorityVotes %}
+                <div title="{{ vote.user.username }} ({{ vote.priority|number_format }})" class="forum__priority__vote" style="--user-colour: {{ vote.user.colour }}">
+                    {% for i in 1..vote.priority %}
                         <span class="forum__priority__star fas fa-star fa-fw"></span>
                     {% endfor %}
                 </div>
             {% endfor %}
         </div>
 
-        {% if can_vote %}
+        {% if topic.canVoteOnPriority(user) %}
             <div class="forum__priority__input">
-                <a class="input__button" href="{{ url('forum-topic-priority', {'topic':topic.topic_id}) }}">
+                <a class="input__button" href="{{ url('forum-topic-priority', {'topic': topic.id}) }}">
                     Vote for this feature
                 </a>
             </div>
diff --git a/templates/forum/master.twig b/templates/forum/master.twig
index fc530f21..a3f41f59 100644
--- a/templates/forum/master.twig
+++ b/templates/forum/master.twig
@@ -1 +1,9 @@
 {% extends 'master.twig' %}
+
+{% if forum_info is not defined and topic_info.category is defined %}
+    {% set forum_info = topic_info.category %}
+{% endif %}
+
+{% if forum_info.colour.raw is defined %}
+    {% set global_accent_colour = forum_info.colour %}
+{% endif %}
diff --git a/templates/forum/posting.twig b/templates/forum/posting.twig
index 3de2cec4..7a1b0f70 100644
--- a/templates/forum/posting.twig
+++ b/templates/forum/posting.twig
@@ -5,32 +5,34 @@
 
 {% set title = 'Posting' %}
 {% set is_reply = posting_topic is defined %}
-{% set is_opening = not is_reply or posting_post.is_opening_post|default(false) %}
+{% set is_opening = not is_reply or posting_post.isOpeningPost|default(false) %}
 
 {% block content %}
     <form method="post" action="{{ url('forum-' ~ (is_reply ? 'post' : 'topic') ~ '-create') }}">
-        {{ input_hidden('post[' ~ (is_reply ? 'topic' : 'forum') ~ ']', is_reply ? posting_topic.topic_id : posting_forum.forum_id) }}
+        {{ input_hidden('post[' ~ (is_reply ? 'topic' : 'forum') ~ ']', is_reply ? posting_topic.id : posting_forum.id) }}
         {{ input_hidden('post[mode]', posting_mode) }}
         {{ input_csrf() }}
         {{ forum_header(
+            posting_forum,
+            true,
             is_reply and not is_opening
-                ? posting_topic.topic_title
+                ? url('forum-topic', {'topic': posting_topic.id})
+                : '',
+            [],
+            true,
+            is_reply and not is_opening
+                ? posting_topic.title
                 : input_text(
                     'post[title]',
                     'forum__header__input',
-                    posting_defaults.title|default(posting_topic.topic_title|default('')),
+                    posting_defaults.title|default(posting_topic.title|default('')),
                     'text',
                     'Enter your title here...'
-                ),
-            posting_breadcrumbs,
-            false,
-            is_reply and not is_opening
-                ? url('forum-topic', {'topic': posting_topic.topic_id})
-                : ''
+                )
         ) }}
 
         {% if posting_post is defined %}
-            {{ input_hidden('post[id]', posting_post.post_id) }}
+            {{ input_hidden('post[id]', posting_post.id) }}
         {% endif %}
 
         {% if posting_notices|length > 0 %}
@@ -43,21 +45,21 @@
             </div>
         {% endif %}
 
-        <div class="container forum__post js-forum-posting" style="{{ posting_post.poster_colour|default(current_user.colour.raw)|html_colour('--accent-colour') }}">
+        <div class="container forum__post js-forum-posting" style="{{ posting_post.user.colour.raw|default(current_user.colour.raw)|html_colour('--accent-colour') }}">
             <div class="forum__post__info">
                 <div class="forum__post__info__background"></div>
                 <div class="forum__post__info__content">
-                    <span class="forum__post__avatar">{{ avatar(posting_post.poster_id|default(current_user.id), 120, posting_post.poster_name|default(current_user.username)) }}</span>
+                    <span class="forum__post__avatar">{{ avatar(posting_post.user.id|default(current_user.id), 120, posting_post.user.username|default(current_user.username)) }}</span>
 
-                    <span class="forum__post__username">{{ posting_post.poster_name|default(current_user.username) }}</span>
+                    <span class="forum__post__username">{{ posting_post.user.username|default(current_user.username) }}</span>
 
                     <div class="forum__post__icons">
-                        <div class="flag flag--{{ posting_post.poster_country|default(posting_info.user_country)|lower }}" title="{{ posting_post.poster_country|default(posting_info.user_country)|country_name }}"></div>
-                        <div class="forum__post__posts-count">{{ posting_post.poster_post_count|default(posting_info.user_forum_posts)|number_format }} posts</div>
+                        <div class="flag flag--{{ posting_post.user.country|default(posting_user.country)|lower }}" title="{{ posting_post.user.country|default(posting_user.country)|country_name }}"></div>
+                        <div class="forum__post__posts-count">{{ posting_post.user.forumPostCount|default(posting_user.forumPostCount)|number_format }} posts</div>
                     </div>
 
                     <div class="forum__post__joined">
-                        joined <time datetime="{{ posting_post.poster_joined|default(posting_info.user_created)|date('c') }}" title="{{ posting_post.poster_joined|default(posting_info.user_created)|date('r') }}">{{ posting_post.poster_joined|default(posting_info.user_created)|time_diff }}</time>
+                        joined <time datetime="{{ posting_post.user.createdTime|default(posting_user.createdTime)|date('c') }}" title="{{ posting_post.user.createdTime|default(posting_user.createdTime)|date('r') }}">{{ posting_post.user.createdTime|default(posting_user.createdTime)|time_diff }}</time>
                     </div>
                 </div>
             </div>
@@ -75,7 +77,7 @@
                     </span>
                 </div>
 
-                <textarea name="post[text]" class="forum__post__text forum__post__text--edit js-forum-posting-text js-ctrl-enter-submit" placeholder="Type your post content here...">{{ posting_defaults.text|default(posting_post.post_text|default('')) }}</textarea>
+                <textarea name="post[text]" class="forum__post__text forum__post__text--edit js-forum-posting-text js-ctrl-enter-submit" placeholder="Type your post content here...">{{ posting_defaults.text|default(posting_post.body|default('')) }}</textarea>
                 <div class="forum__post__text js-forum-posting-preview" hidden></div>
 
                 <div class="forum__post__actions forum__post__actions--bbcode" hidden>
@@ -140,14 +142,14 @@
                         {{ input_select(
                             'post[parser]',
                             constant('\\Misuzu\\Parsers\\Parser::NAMES'),
-                            posting_defaults.parser|default(posting_post.post_parse|default(posting_info.user_post_parse|default(constant('\\Misuzu\\Parsers\\Parser::BBCODE')))),
+                            posting_defaults.parser|default(posting_post.bodyParser|default(posting_user.preferredParser)),
                             null, null, false, 'forum__post__dropdown js-forum-posting-parser'
                         ) }}
                         {% if is_opening and posting_types|length > 1 %}
                             {{ input_select(
                                 'post[type]',
                                 posting_types,
-                                posting_defaults.type|default(posting_topic.topic_type|default(posting_types|keys|first)),
+                                posting_defaults.type|default(posting_topic.type|default(posting_types|keys|first)),
                                 null, null, null, 'forum__post__dropdown'
                             ) }}
                         {% endif %}
@@ -156,8 +158,8 @@
                             'Display Signature',
                             posting_defaults.signature is not null
                                 ? posting_defaults.signature : (
-                                    posting_post.post_display_signature is defined
-                                        ? posting_post.post_display_signature
+                                    posting_post.shouldDisplaySignature is defined
+                                        ? posting_post.shouldDisplaySignature
                                         : true
                                 )
                         ) }}
diff --git a/templates/forum/topic.twig b/templates/forum/topic.twig
index fdeba47c..1ef34c23 100644
--- a/templates/forum/topic.twig
+++ b/templates/forum/topic.twig
@@ -12,58 +12,60 @@
         forum_priority_votes
 %}
 
-{% set title = topic_info.topic_title %}
+{% set title = topic_info.title %}
 {% set canonical_url = url('forum-topic', {
-    'topic': topic_info.topic_id,
+    'topic': topic_info.id,
     'page': topic_pagination.page > 1 ? topic_pagination.page : 0,
 }) %}
 
 {% set forum_post_csrf = csrf_token() %}
 {% set topic_tools = forum_topic_tools(topic_info, topic_pagination, can_reply) %}
-{% set topic_notice = forum_topic_locked(topic_info.topic_locked, topic_info.topic_archived) %}
+{% set topic_notice = forum_topic_locked(topic_info.lockedTime, topic_info.archived) %}
 {% set topic_actions = [
     {
         'html': '<i class="far fa-trash-alt fa-fw"></i> Delete',
-        'url': url('forum-topic-delete', {'topic': topic_info.topic_id}),
+        'url': url('forum-topic-delete', {'topic': topic_info.id}),
         'display': topic_can_delete,
     },
     {
         'html': '<i class="fas fa-magic fa-fw"></i> Restore',
-        'url': url('forum-topic-restore', {'topic': topic_info.topic_id}),
+        'url': url('forum-topic-restore', {'topic': topic_info.id}),
         'display': topic_can_nuke_or_restore,
     },
     {
         'html': '<i class="fas fa-radiation-alt fa-fw"></i> Permanently Delete',
-        'url': url('forum-topic-nuke', {'topic': topic_info.topic_id}),
+        'url': url('forum-topic-nuke', {'topic': topic_info.id}),
         'display': topic_can_nuke_or_restore,
     },
     {
         'html': '<i class="fas fa-plus-circle fa-fw"></i> Bump',
-        'url': url('forum-topic-bump', {'topic': topic_info.topic_id}),
+        'url': url('forum-topic-bump', {'topic': topic_info.id}),
         'display': topic_can_bump,
     },
     {
         'html': '<i class="fas fa-lock fa-fw"></i> Lock',
-        'url': url('forum-topic-lock', {'topic': topic_info.topic_id}),
-        'display': topic_can_lock and topic_info.topic_locked is null,
+        'url': url('forum-topic-lock', {'topic': topic_info.id}),
+        'display': topic_can_lock and not topic_info.locked,
     },
     {
         'html': '<i class="fas fa-lock-open fa-fw"></i> Unlock',
-        'url': url('forum-topic-unlock', {'topic': topic_info.topic_id}),
-        'display': topic_can_lock and topic_info.topic_locked is not null,
+        'url': url('forum-topic-unlock', {'topic': topic_info.id}),
+        'display': topic_can_lock and topic_info.locked,
     },
 ] %}
 
 {% block content %}
-    {{ forum_header(topic_info.topic_title, topic_breadcrumbs, false, canonical_url, topic_actions) }}
+    {{ forum_header(topic_info.category, true, canonical_url, topic_actions, true, topic_info.title) }}
     {{ topic_notice }}
-    {% if forum_has_priority_voting(topic_info.forum_type) %}
-        {{ forum_priority_votes(topic_info, topic_priority_votes, true) }} {# replace true this with perms check #}
+    {% if topic_info.hasPriorityVoting %}
+        {{ forum_priority_votes(topic_info, current_user|default(null)) }}
     {% endif %}
-    {{ forum_poll(topic_info, topic_poll_options, topic_poll_user_answers, topic_info.topic_id, current_user.id|default(0) > 0, topic_info.author_user_id == current_user.id|default(0)) }}
+    {% for poll in topic_info.polls %}
+        {{ forum_poll(poll, current_user|default(null)) }}
+    {% endfor %}
     {{ topic_tools }}
-    {{ forum_post_listing(topic_posts, current_user.id|default(0), topic_perms) }}
+    {{ forum_post_listing(topic_info.posts(topic_can_view_deleted, topic_pagination), current_user|default(null), topic_perms) }}
     {{ topic_tools }}
     {{ topic_notice }}
-    {{ forum_header('', topic_breadcrumbs) }}
+    {{ forum_header(topic_info.category, false) }}
 {% endblock %}
diff --git a/templates/macros.twig b/templates/macros.twig
index 98b4988b..37378a21 100644
--- a/templates/macros.twig
+++ b/templates/macros.twig
@@ -11,63 +11,6 @@
     </ul>
 {% endmacro %}
 
-{% macro pagination(info, path, page_range, params, page_param, url_fragment) %}
-    {% if info.page is defined and info.pages > 1 %}
-        {% set params = params is iterable ? params : [] %}
-        {% set page_param = page_param|default('p') %}
-        {% set page_range = page_range|default(5) %}
-
-        <div class="pagination">
-            <div class="pagination__section pagination__section--first">
-                {% if info.page <= 1 %}
-                    <div class="pagination__link pagination__link--first pagination__link--disabled">
-                        <i class="fas fa-angle-double-left"></i>
-                    </div>
-                    <div class="pagination__link pagination__link--prev pagination__link--disabled">
-                        <i class="fas fa-angle-left"></i>
-                    </div>
-                {% else %}
-                    <a href="{{ url_construct(path, params, url_fragment) }}" class="pagination__link pagination__link--first" rel="first">
-                        <i class="fas fa-angle-double-left"></i>
-                    </a>
-                    <a href="{{ url_construct(path, info.page <= 2 ? params : params|merge({(page_param): info.page - 1}), url_fragment) }}" class="pagination__link pagination__link--prev" rel="prev">
-                        <i class="fas fa-angle-left"></i>
-                    </a>
-                {% endif %}
-            </div>
-
-            <div class="pagination__section pagination__section--pages">
-                {% set p_start = max(info.page - page_range, 1) %}
-                {% set p_stop = min(info.page + page_range, info.pages) %}
-
-                {% for i in p_start..p_stop %}
-                    <a href="{{ url_construct(path, i <= 1 ? params : params|merge({(page_param): i}), url_fragment) }}" class="pagination__link{{ info.page == i ? ' pagination__link--current' : '' }}">
-                        {{ i }}
-                    </a>
-                {% endfor %}
-            </div>
-
-            <div class="pagination__section pagination__section--last">
-                {% if info.page >= info.pages %}
-                    <div class="pagination__link pagination__link--next pagination__link--disabled">
-                        <i class="fas fa-angle-right"></i>
-                    </div>
-                    <div class="pagination__link pagination__link--last pagination__link--disabled">
-                        <i class="fas fa-angle-double-right"></i>
-                    </div>
-                {% else %}
-                    <a href="{{ url_construct(path, params|merge({(page_param): info.page + 1}), url_fragment) }}" class="pagination__link pagination__link--next" rel="next">
-                        <i class="fas fa-angle-right"></i>
-                    </a>
-                    <a href="{{ url_construct(path, params|merge({(page_param): info.pages}), url_fragment) }}" class="pagination__link pagination__link--last" rel="last">
-                        <i class="fas fa-angle-double-right"></i>
-                    </a>
-                {% endif %}
-            </div>
-        </div>
-    {% endif %}
-{% endmacro %}
-
 {% macro container_title(title, unsafe, url) %}
     {% set has_url = url is not null and url|length > 0 %}
 
@@ -86,5 +29,5 @@
 {% endmacro %}
 
 {% macro avatar(user_id, resolution, alt_text, attributes) %}
-{{ html_avatar(user_id, resolution, alt_text|default(''), attributes|default([]))|raw }}
+    <img src="{{ url('user-avatar', {'user': user_id|default(0), 'res': resolution|default(0) * 2}) }}" alt="{{ alt_text|default('') }}" class="{{ ('avatar ' ~ attributes.class|default(''))|trim }}"{% if resolution > 0 %} width="{{ resolution }}" height="{{ resolution }}"{% endif %}/>
 {% endmacro %}
diff --git a/templates/manage/changelog/changes.twig b/templates/manage/changelog/changes.twig
index 40099b02..3a4541ee 100644
--- a/templates/manage/changelog/changes.twig
+++ b/templates/manage/changelog/changes.twig
@@ -1,9 +1,9 @@
 {% extends 'manage/changelog/master.twig' %}
-{% from 'macros.twig' import pagination, container_title %}
+{% from 'macros.twig' import container_title %}
 {% from 'changelog/macros.twig' import changelog_listing %}
 
 {% block manage_content %}
-    {% set changelog_pagination = pagination(changelog_pagination, url('manage-changelog-changes')) %}
+    {% set changelog_pagination = changelog_pagination.render('manage-changelog-changes') %}
 
 
     <div class="container">
@@ -12,13 +12,13 @@
         <div class="changelog__content">
             <div class="changelog__pagination">
                 <a href="{{ url('manage-changelog-change') }}" class="input__button">Create new change</a>
-                {{ changelog_pagination }}
+                {{ changelog_pagination|raw }}
             </div>
 
             {{ changelog_listing(changelog_changes, false, false, true) }}
 
             <div class="changelog__pagination">
-                {{ changelog_pagination }}
+                {{ changelog_pagination|raw }}
             </div>
         </div>
     </div>
diff --git a/templates/manage/general/logs.twig b/templates/manage/general/logs.twig
index d2e3bcd1..97111f76 100644
--- a/templates/manage/general/logs.twig
+++ b/templates/manage/general/logs.twig
@@ -1,15 +1,15 @@
 {% extends 'manage/general/master.twig' %}
-{% from 'macros.twig' import container_title, pagination %}
+{% from 'macros.twig' import container_title, %}
 {% from 'user/macros.twig' import user_account_log %}
 
 {% block manage_content %}
     <div class="container settings__container">
         {{ container_title('<i class="fas fa-file-alt fa-fw"></i> Global Log') }}
-        {% set glp = pagination(global_logs_pagination, url('manage-general-logs'), null, {'v': 'logs'}) %}
+        {% set glp = global_logs_pagination.render('manage-general-logs', {'v': 'logs'}) %}
 
         <div class="settings__account-logs">
             <div class="settings__account-logs__pagination">
-                {{ glp }}
+                {{ glp|raw }}
             </div>
 
             {% for log in global_logs %}
@@ -17,7 +17,7 @@
             {% endfor %}
 
             <div class="settings__account-logs__pagination">
-                {{ glp }}
+                {{ glp|raw }}
             </div>
         </div>
     </div>
diff --git a/templates/manage/news/categories.twig b/templates/manage/news/categories.twig
index 995c502b..006ab878 100644
--- a/templates/manage/news/categories.twig
+++ b/templates/manage/news/categories.twig
@@ -1,5 +1,5 @@
 {% extends 'manage/news/master.twig' %}
-{% from 'macros.twig' import pagination, container_title %}
+{% from 'macros.twig' import container_title %}
 
 {% block manage_content %}
     <div class="container">
@@ -16,6 +16,6 @@
             </p>
         {% endfor %}
 
-        {{ pagination(categories_pagination, url('manage-news-categories')) }}
+        {{ categories_pagination.render('manage-news-categories')|raw }}
     </div>
 {% endblock %}
diff --git a/templates/manage/news/posts.twig b/templates/manage/news/posts.twig
index d7abc802..9bfc0c8b 100644
--- a/templates/manage/news/posts.twig
+++ b/templates/manage/news/posts.twig
@@ -1,5 +1,5 @@
 {% extends 'manage/news/master.twig' %}
-{% from 'macros.twig' import pagination, container_title %}
+{% from 'macros.twig' import container_title %}
 
 {% block manage_content %}
     <div class="container">
@@ -22,6 +22,6 @@
             </p>
         {% endfor %}
 
-        {{ pagination(posts_pagination, url('manage-news-posts')) }}
+        {{ posts_pagination.render('manage-news-posts') }}
     </div>
 {% endblock %}
diff --git a/templates/manage/users/roles.twig b/templates/manage/users/roles.twig
index 06a41216..e9ecbdf1 100644
--- a/templates/manage/users/roles.twig
+++ b/templates/manage/users/roles.twig
@@ -1,7 +1,7 @@
 {% extends 'manage/users/master.twig' %}
-{% from 'macros.twig' import pagination, container_title %}
+{% from 'macros.twig' import container_title %}
 
-{% set roles_pagination = pagination(manage_roles_pagination, url('manage-roles')) %}
+{% set roles_pagination = manage_roles_pagination.render('manage-roles') %}
 
 {% block manage_content %}
     <div class="container manage__roles">
@@ -9,7 +9,7 @@
 
         {% if roles_pagination|trim|length > 0 %}
             <div class="manage__roles__pagination">
-                {{ roles_pagination }}
+                {{ roles_pagination|raw }}
             </div>
         {% endif %}
 
@@ -71,7 +71,7 @@
 
         {% if roles_pagination|trim|length > 0 %}
             <div class="manage__roles__pagination">
-                {{ roles_pagination }}
+                {{ roles_pagination|raw }}
             </div>
         {% endif %}
     </div>
diff --git a/templates/manage/users/users.twig b/templates/manage/users/users.twig
index b2156c8e..f268665a 100644
--- a/templates/manage/users/users.twig
+++ b/templates/manage/users/users.twig
@@ -1,7 +1,7 @@
 {% extends 'manage/users/master.twig' %}
-{% from 'macros.twig' import pagination, container_title, avatar %}
+{% from 'macros.twig' import container_title, avatar %}
 
-{% set users_pagination = pagination(manage_users_pagination, url('manage-users')) %}
+{% set users_pagination = manage_users_pagination.render('manage-users') %}
 
 {% block manage_content %}
     <div class="container manage__users">
@@ -9,7 +9,7 @@
 
         {% if users_pagination|trim|length > 0 %}
             <div class="manage__users__pagination">
-                {{ users_pagination }}
+                {{ users_pagination|raw }}
             </div>
         {% endif %}
 
@@ -55,7 +55,7 @@
 
         {% if users_pagination|trim|length > 0 %}
             <div class="manage__users__pagination">
-                {{ users_pagination }}
+                {{ users_pagination|raw }}
             </div>
         {% endif %}
     </div>
diff --git a/templates/manage/users/warnings.twig b/templates/manage/users/warnings.twig
index 776d0722..987d96e0 100644
--- a/templates/manage/users/warnings.twig
+++ b/templates/manage/users/warnings.twig
@@ -1,5 +1,5 @@
 {% extends 'manage/users/master.twig' %}
-{% from 'macros.twig' import pagination, container_title %}
+{% from 'macros.twig' import container_title %}
 {% from 'user/macros.twig' import user_profile_warning %}
 {% from '_layout/input.twig' import input_text, input_csrf, input_select, input_hidden %}
 
@@ -38,9 +38,9 @@
 
     <div class="container container--lazy">
         {{ container_title('<i class="fas fa-exclamation-circle fa-fw"></i> Warnings') }}
-        {% set warnpag = pagination(warnings.pagination, url('manage-users-warnings', {'user': warnings.user.id|default(0)})) %}
+        {% set warnpag = warnings.pagination.render('manage-users-warnings', {'user': warnings.user.id|default(0)}) %}
 
-        {{ warnpag }}
+        {{ warnpag|raw }}
 
         <div class="profile__warnings__container">
             <div class="profile__warning profile__warning--extendo">
@@ -90,6 +90,6 @@
             {% endfor %}
         </div>
 
-        {{ warnpag }}
+        {{ warnpag|raw }}
     </div>
 {% endblock %}
diff --git a/templates/master.twig b/templates/master.twig
index 5d02e1e8..9129a089 100644
--- a/templates/master.twig
+++ b/templates/master.twig
@@ -26,7 +26,7 @@
 {% endif %}
     </head>
     <body class="main{% if site_background is defined %} {{ site_background.classNames('main--bg-%s')|join(' ') }}{% endif %}"
-        style="{% if global_accent_colour is defined %}{{ global_accent_colour|html_colour('--accent-colour') }}{% endif %}" id="container">
+        style="{% if global_accent_colour.raw is defined %}--accent-colour: {{ global_accent_colour }}{% elseif global_accent_colour is defined %}{{ global_accent_colour|html_colour('--accent-colour') }}{% endif %}" id="container">
 {% include '_layout/header.twig' %}
 
         <div class="main__wrapper">
diff --git a/templates/news/category.twig b/templates/news/category.twig
index afe804bf..31b73be3 100644
--- a/templates/news/category.twig
+++ b/templates/news/category.twig
@@ -1,5 +1,5 @@
 {% extends 'news/master.twig' %}
-{% from 'macros.twig' import pagination, container_title %}
+{% from 'macros.twig' import container_title %}
 {% from 'news/macros.twig' import news_preview %}
 
 {% set title = category_info.name ~ ' :: News' %}
@@ -30,7 +30,7 @@
             {% endfor %}
 
             <div class="container" style="padding: 4px; display: {{ news_pagination.pages > 1 ? 'block' : 'none' }}">
-                {{ pagination(news_pagination, url('news-category', {'category':category_info.id})) }}
+                {{ news_pagination.render(url('news-category', {'category':category_info.id}))|raw }}
             </div>
         </div>
 
diff --git a/templates/news/index.twig b/templates/news/index.twig
index ec7747c0..2d4dee13 100644
--- a/templates/news/index.twig
+++ b/templates/news/index.twig
@@ -1,5 +1,5 @@
 {% extends 'news/master.twig' %}
-{% from 'macros.twig' import pagination, container_title %}
+{% from 'macros.twig' import container_title %}
 {% from 'news/macros.twig' import news_preview %}
 
 {% set title = 'News' %}
@@ -29,7 +29,7 @@
             {% endfor %}
 
             <div class="container" style="padding: 4px; display: {{ news_pagination.pages > 1 ? 'block' : 'none' }}">
-                {{ pagination(news_pagination, url('news-index')) }}
+                {{ news_pagination.render('news-index')|raw }}
             </div>
         </div>
 
diff --git a/templates/profile/posts.twig b/templates/profile/posts.twig
index bbb02b66..005376ee 100644
--- a/templates/profile/posts.twig
+++ b/templates/profile/posts.twig
@@ -1,23 +1,13 @@
 {% extends 'profile/master.twig' %}
-{% from 'macros.twig' import pagination %}
-{% from 'forum/macros.twig' import forum_post_listing %}
 
 {% block content %}
     <div class="profile">
         {% include 'profile/_layout/header.twig' %}
 
-        {% set sp = profile_posts_pagination.pages > 1
-            ? '<div class="container profile__pagination">' ~ pagination(profile_posts_pagination, canonical_url) ~ '</div>'
-            : '' %}
-
-        {% if sp is not empty %}
-            {{ sp|raw }}
-        {% endif %}
-
-        {{ forum_post_listing(profile_posts) }}
-
-        {% if sp is not empty %}
-            {{ sp|raw }}
-        {% endif %}
+        <div class="warning">
+            <div class="warning__content">
+                <p>User post listing is gone for a while, it will be back someday but with less bad.</p>
+            </div>
+        </div>
     </div>
 {% endblock %}
diff --git a/templates/profile/relations.twig b/templates/profile/relations.twig
index 3fadaa72..a8d462f0 100644
--- a/templates/profile/relations.twig
+++ b/templates/profile/relations.twig
@@ -1,5 +1,4 @@
 {% extends 'profile/master.twig' %}
-{% from 'macros.twig' import pagination %}
 {% from 'user/macros.twig' import user_card %}
 
 {% block content %}
@@ -16,7 +15,7 @@
 
         {% if profile_relation_pagination.pages > 1 %}
             <div class="container profile__pagination">
-                {{ pagination(profile_relation_pagination, canonical_url) }}
+                {{ profile_relation_pagination.render(canonical_url)|raw }}
             </div>
         {% endif %}
     </div>
diff --git a/templates/profile/topics.twig b/templates/profile/topics.twig
index 12773026..e4bbc5d0 100644
--- a/templates/profile/topics.twig
+++ b/templates/profile/topics.twig
@@ -1,23 +1,13 @@
 {% extends 'profile/master.twig' %}
-{% from 'macros.twig' import pagination %}
-{% from 'forum/macros.twig' import forum_topic_listing %}
 
 {% block content %}
     <div class="profile">
         {% include 'profile/_layout/header.twig' %}
 
-        {% set sp = profile_topics_pagination.pages > 1
-            ? '<div class="container profile__pagination">' ~ pagination(profile_topics_pagination, canonical_url) ~ '</div>'
-            : '' %}
-
-        {% if sp is not empty %}
-            {{ sp|raw }}
-        {% endif %}
-
-        {{ forum_topic_listing(profile_topics) }}
-
-        {% if sp is not empty %}
-            {{ sp|raw }}
-        {% endif %}
+        <div class="warning">
+            <div class="warning__content">
+                <p>User topic listing is gone for a while, it will be back someday but with less bad.</p>
+            </div>
+        </div>
     </div>
 {% endblock %}
diff --git a/templates/settings/logs.twig b/templates/settings/logs.twig
index e2c02136..92935192 100644
--- a/templates/settings/logs.twig
+++ b/templates/settings/logs.twig
@@ -1,5 +1,5 @@
 {% extends 'settings/master.twig' %}
-{% from 'macros.twig' import container_title, pagination %}
+{% from 'macros.twig' import container_title %}
 {% from 'user/macros.twig' import user_login_attempt, user_account_log %}
 
 {% set title = 'Settings / Logs' %}
@@ -7,7 +7,7 @@
 {% block settings_content %}
     <div class="container settings__container" id="login-history">
         {{ container_title('<i class="fas fa-user-lock fa-fw"></i> Login History') }}
-        {% set lhpagination = pagination(login_history_pagination, url('settings-logs'), null, {
+        {% set lhpagination = login_history_pagination.render('settings-logs', {
             'ap': account_log_pagination.page > 1 ? account_log_pagination.page : 0,
         }, 'hp', 'login-history') %}
 
@@ -17,7 +17,7 @@
 
         <div class="settings__login-attempts">
             <div class="settings__login-attempts__pagination">
-                {{ lhpagination }}
+                {{ lhpagination|raw }}
             </div>
 
             {% if login_history_list|length < 1 %}
@@ -31,14 +31,14 @@
             {% endif %}
 
             <div class="settings__login-attempts__pagination">
-                {{ lhpagination }}
+                {{ lhpagination|raw }}
             </div>
         </div>
     </div>
 
     <div class="container settings__container" id="account-log">
         {{ container_title('<i class="fas fa-file-alt fa-fw"></i> Account Log') }}
-        {% set alpagination = pagination(account_log_pagination, url('settings-logs'), null, {
+        {% set alpagination = account_log_pagination.render('settings-logs', {
             'hp': login_history_pagination.page > 1 ? login_history_pagination.page : 0,
         }, 'ap', 'account-log') %}
 
@@ -48,7 +48,7 @@
 
         <div class="settings__account-logs">
             <div class="settings__account-logs__pagination">
-                {{ alpagination }}
+                {{ alpagination|raw }}
             </div>
 
             {% for log in account_log_list %}
@@ -56,7 +56,7 @@
             {% endfor %}
 
             <div class="settings__account-logs__pagination">
-                {{ alpagination }}
+                {{ alpagination|raw }}
             </div>
         </div>
     </div>
diff --git a/templates/settings/sessions.twig b/templates/settings/sessions.twig
index 5c13bbb2..dfaea636 100644
--- a/templates/settings/sessions.twig
+++ b/templates/settings/sessions.twig
@@ -1,5 +1,5 @@
 {% extends 'settings/master.twig' %}
-{% from 'macros.twig' import container_title, pagination %}
+{% from 'macros.twig' import container_title %}
 {% from 'user/macros.twig' import user_session %}
 {% from '_layout/input.twig' import input_hidden, input_csrf %}
 
@@ -9,7 +9,7 @@
     <div class="container settings__container">
         {{ container_title('<i class="fas fa-key fa-fw"></i> Sessions') }}
 
-        {% set spagination = pagination(session_pagination, url('settings-sessions')) %}
+        {% set spagination = session_pagination.render('settings-sessions') %}
 
         <div class="settings__description">
             <p>These are the active logins to your account, clicking the Kill button will force a logout on that session. Your current login is highlighted with a different colour so you don't accidentally force yourself to logout.</p>
@@ -26,7 +26,7 @@
             </form>
 
             <div class="settings__sessions__pagination">
-                {{ spagination }}
+                {{ spagination|raw }}
             </div>
 
             <div class="settings__sessions__list">
@@ -36,7 +36,7 @@
             </div>
 
             <div class="settings__sessions__pagination">
-                {{ spagination }}
+                {{ spagination|raw }}
             </div>
         </div>
     </div>
diff --git a/templates/user/listing.twig b/templates/user/listing.twig
index 64b3b3fd..b8f6d3fe 100644
--- a/templates/user/listing.twig
+++ b/templates/user/listing.twig
@@ -15,7 +15,6 @@
 {% set manage_link = url('manage-users') %}
 
 {% macro member_nav(roles, role_id, orders, order, directions, direction, users_pagination, url_role, url_sort, url_direction) %}
-    {% from 'macros.twig' import pagination %}
     {% from '_layout/input.twig' import input_select %}
 
     <div class="userlist__navigation">
@@ -30,7 +29,7 @@
         </form>
 
         <div class="userlist__pagination">
-            {{ pagination(users_pagination, url('user-list'), null, {'r': url_role, 'ss': url_sort, 'sd': url_direction}) }}
+            {{ users_pagination.render('user-list', {'r': url_role, 'ss': url_sort, 'sd': url_direction})|raw }}
         </div>
     </div>
 {% endmacro %}
diff --git a/utility.php b/utility.php
index ebed408f..32960a4b 100644
--- a/utility.php
+++ b/utility.php
@@ -33,16 +33,21 @@ function clamp($num, int $min, int $max): int {
     return max($min, min($max, intval($num)));
 }
 
-function starts_with(string $string, string $text, bool $multibyte = true): bool {
+function starts_with(string $haystack, string $needle, bool $multibyte = true): bool {
     $strlen = $multibyte ? 'mb_strlen' : 'strlen';
     $substr = $multibyte ? 'mb_substr' : 'substr';
-    return $substr($string, 0, $strlen($text)) === $text;
+    return $substr($haystack, 0, $strlen($needle)) === $needle;
 }
 
-function ends_with(string $string, string $text, bool $multibyte = true): bool {
+function ends_with(string $haystack, string $needle, bool $multibyte = true): bool {
     $strlen = $multibyte ? 'mb_strlen' : 'strlen';
     $substr = $multibyte ? 'mb_substr' : 'substr';
-    return $substr($string, 0 - $strlen($text)) === $text;
+    return $substr($haystack, 0 - $strlen($needle)) === $needle;
+}
+
+if(!function_exists('str_starts_with')) {
+    function str_starts_with(string $haystack, string $needle): bool { return starts_with($haystack, $needle, false); }
+    function str_ends_with(string $haystack, string $needle): bool { return ends_with($haystack, $needle, false); }
 }
 
 function first_paragraph(string $text, string $delimiter = "\n"): string {
@@ -79,22 +84,14 @@ function byte_symbol(int $bytes, bool $decimal = false, array $symbols = ['', 'K
     return sprintf("%.2f %s%sB", $bytes, $symbol, $symbol !== '' && !$decimal ? 'i' : '');
 }
 
-function get_country_name(string $code): string {
-    switch(strtolower($code)) {
-        case 'xx':
-            return 'Unknown';
-
-        case 'a1':
-            return 'Anonymous Proxy';
-
-        case 'a2':
-            return 'Satellite Provider';
-
-        default:
-            return locale_get_display_region("-{$code}", 'en');
-    }
+function get_country_name(string $code, string $locale = 'en'): string {
+    if($code === 'xx')
+        return 'Unknown';
+    return \Locale::getDisplayRegion("-{$code}", $locale);
 }
 
+/*** THE DEPRECATION NATION (LINE) ***/
+
 // render_error, render_info and render_info_or_json should be redone a bit better
 // following a uniform format so there can be a global handler for em
 
@@ -161,37 +158,3 @@ 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 ?? 0, 'res' => $resolution * 2]);
-    $attributes['alt'] = $altText;
-    $attributes['class'] = trim('avatar ' . ($attributes['class'] ?? ''));
-
-    if(!isset($attributes['width']))
-        $attributes['width'] = $resolution;
-    if(!isset($attributes['height']))
-        $attributes['height'] = $resolution;
-
-    return html_tag('img', $attributes);
-}
-
-function html_tag(string $name, array $atrributes = [], ?bool $close = null, string $content = ''): string {
-    $html = '<' . $name;
-
-    foreach($atrributes as $key => $value) {
-        $html .= ' ' . $key;
-
-        if(!empty($value))
-            $html .= '="' . $value . '"';
-    }
-
-    if($close === false)
-        $html .= '/';
-
-    $html .= '>';
-
-    if($close === true)
-        $html .= $content . '</' . $name . '>';
-
-    return $html;
-}