diff --git a/extensions/mentions/.editorconfig b/extensions/mentions/.editorconfig
index 87694ddab..dc9d5b361 100644
--- a/extensions/mentions/.editorconfig
+++ b/extensions/mentions/.editorconfig
@@ -15,5 +15,5 @@ indent_size = 2
 [*.{diff,md}]
 trim_trailing_whitespace = false
 
-[*.php]
+[*.{php,json}]
 indent_size = 4
diff --git a/extensions/mentions/.github/workflows/test.yml b/extensions/mentions/.github/workflows/test.yml
new file mode 100644
index 000000000..d3cfc5a82
--- /dev/null
+++ b/extensions/mentions/.github/workflows/test.yml
@@ -0,0 +1,78 @@
+name: Tests
+
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+    strategy:
+      matrix:
+        php: [7.3, 7.4, '8.0']
+        service: ['mysql:5.7', mariadb]
+        prefix: ['', flarum_]
+
+        include:
+          - service: 'mysql:5.7'
+            db: MySQL
+          - service: mariadb
+            db: MariaDB
+          - prefix: flarum_
+            prefixStr: (prefix)
+
+        exclude:
+          - php: 7.3
+            service: 'mysql:5.7'
+            prefix: flarum_
+          - php: 7.3
+            service: mariadb
+            prefix: flarum_
+          - php: 8.0
+            service: 'mysql:5.7'
+            prefix: flarum_
+          - php: 8.0
+            service: mariadb
+            prefix: flarum_
+
+    services:
+      mysql:
+        image: ${{ matrix.service }}
+        ports:
+          - 13306:3306
+
+    name: 'PHP ${{ matrix.php }} / ${{ matrix.db }} ${{ matrix.prefixStr }}'
+
+    steps:
+      - uses: actions/checkout@master
+
+      - name: Setup PHP
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php }}
+          coverage: xdebug
+          extensions: curl, dom, gd, json, mbstring, openssl, pdo_mysql, tokenizer, zip
+          tools: phpunit, composer:v2
+
+      # The authentication alter is necessary because newer mysql versions use the `caching_sha2_password` driver,
+      # which isn't supported prior to PHP7.4
+      # When we drop support for PHP7.3, we should remove this from the setup.
+      - name: Create MySQL Database
+        run: |
+          sudo systemctl start mysql
+          mysql -uroot -proot -e 'CREATE DATABASE flarum_test;' --port 13306
+          mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" --port 13306
+
+      - name: Install Composer dependencies
+        run: composer install
+
+      - name: Setup Composer tests
+        run: composer test:setup
+        env:
+          DB_PORT: 13306
+          DB_PASSWORD: root
+          DB_PREFIX: ${{ matrix.prefix }}
+
+      - name: Run Composer tests
+        run: composer test
+        env:
+          COMPOSER_PROCESS_TIMEOUT: 600
diff --git a/extensions/mentions/.gitignore b/extensions/mentions/.gitignore
index 7f43257e7..36f90627a 100644
--- a/extensions/mentions/.gitignore
+++ b/extensions/mentions/.gitignore
@@ -1,6 +1,9 @@
 /vendor
+composer.lock
 composer.phar
 .DS_Store
 Thumbs.db
+tests/.phpunit.result.cache
+/tests/integration/tmp
 node_modules
 js/dist/*
diff --git a/extensions/mentions/composer.json b/extensions/mentions/composer.json
index 625bc5fe1..07db558de 100644
--- a/extensions/mentions/composer.json
+++ b/extensions/mentions/composer.json
@@ -2,7 +2,9 @@
     "name": "flarum/mentions",
     "description": "Mention and reply to specific posts and users.",
     "type": "flarum-extension",
-    "keywords": ["discussion"],
+    "keywords": [
+        "discussion"
+    ],
     "license": "MIT",
     "support": {
         "issues": "https://github.com/flarum/core/issues",
@@ -37,5 +39,24 @@
                 "color": "#fff"
             }
         }
+    },
+    "scripts": {
+        "test": [
+            "@test:unit",
+            "@test:integration"
+        ],
+        "test:unit": "phpunit -c tests/phpunit.unit.xml",
+        "test:integration": "phpunit -c tests/phpunit.integration.xml",
+        "test:setup": "@php tests/integration/setup.php"
+    },
+    "scripts-descriptions": {
+        "test": "Runs all tests.",
+        "test:unit": "Runs all unit tests.",
+        "test:integration": "Runs all integration tests.",
+        "test:setup": "Sets up a database for use with integration tests. Execute this only once."
+    },
+    "require-dev": {
+        "flarum/core": "0.1.x-dev",
+        "flarum/testing": "^0.1.0-beta.16"
     }
 }
diff --git a/extensions/mentions/tests/fixtures/.gitkeep b/extensions/mentions/tests/fixtures/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/extensions/mentions/tests/integration/api/PostMentionsTest.php b/extensions/mentions/tests/integration/api/PostMentionsTest.php
new file mode 100644
index 000000000..5d88dc8c2
--- /dev/null
+++ b/extensions/mentions/tests/integration/api/PostMentionsTest.php
@@ -0,0 +1,207 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Mentions\Tests\integration\api;
+
+use Carbon\Carbon;
+use Flarum\Extend;
+use Flarum\Post\CommentPost;
+use Flarum\Testing\integration\RetrievesAuthorizedUsers;
+use Flarum\Testing\integration\TestCase;
+use Flarum\Testing\integration\UsesSettings;
+use Flarum\User\DisplayName\DriverInterface;
+use Flarum\User\User;
+
+class PostMentionsTest extends TestCase
+{
+    use RetrievesAuthorizedUsers;
+    use UsesSettings;
+
+    /**
+     * @inheritDoc
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->extension('flarum-mentions');
+
+        $this->prepareDatabase([
+            'users' => [
+                ['id' => 3, 'username' => 'potato', 'email' => 'potato@machine.local', 'is_email_confirmed' => 1],
+                ['id' => 4, 'username' => 'toby', 'email' => 'toby@machine.local', 'is_email_confirmed' => 1],
+            ],
+            'discussions' => [
+                ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2],
+            ],
+            'posts' => [
+                ['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="TobyFlarum___" id="5" number="2" discussionid="2" username="toby">@tobyuuu#5</POSTMENTION></r>'],
+                ['id' => 5, 'number' => 3, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 4, 'type' => 'comment', 'content' => '<r><POSTMENTION displayname="potato" id="4" number="3" discussionid="2" username="potato">@potato#4</POSTMENTION></r>'],
+            ],
+            'post_mentions_post' => [
+                ['post_id' => 4, 'mentions_post_id' => 5],
+                ['post_id' => 5, 'mentions_post_id' => 4]
+            ],
+            'settings' => [
+                ['key' => 'display_name_driver', 'value' => 'custom_display_name_driver'],
+            ],
+        ]);
+
+        $this->extend(
+            (new Extend\User)
+                ->displayNameDriver('custom_display_name_driver', CustomOtherDisplayNameDriver::class)
+        );
+    }
+
+    /**
+     * Purge the settings cache and reset the new display name driver.
+     */
+    protected function recalculateDisplayNameDriver()
+    {
+        $this->purgeSettingsCache();
+        $container = $this->app()->getContainer();
+        $container->forgetInstance('flarum.user.display_name.driver');
+        User::setDisplayNameDriver($container->make('flarum.user.display_name.driver'));
+    }
+
+    /**
+     * @test
+     */
+    public function mentioning_a_valid_post_works()
+    {
+        $this->app();
+        $this->recalculateDisplayNameDriver();
+
+        $response = $this->send(
+            $this->request('POST', '/api/posts', [
+                'authenticatedAs' => 1,
+                'json' => [
+                    'data' => [
+                        'attributes' => [
+                            'content' => '@potato#4',
+                        ],
+                        'relationships' => [
+                            'discussion' => ['data' => ['id' => 2]],
+                        ],
+                    ],
+                ],
+            ])
+        );
+
+        $this->assertEquals(201, $response->getStatusCode());
+
+        $response = json_decode($response->getBody(), true);
+
+        $this->assertStringContainsString('POTATO$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringContainsString('@potato#4', $response['data']['attributes']['content']);
+        $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
+        $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsPosts->find(4));
+    }
+
+    /**
+     * @test
+     */
+    public function mentioning_an_invalid_post_doesnt_work()
+    {
+        $this->app();
+        $this->recalculateDisplayNameDriver();
+
+        $response = $this->send(
+            $this->request('POST', '/api/posts', [
+                'authenticatedAs' => 1,
+                'json' => [
+                    'data' => [
+                        'attributes' => [
+                            'content' => '@franzofflarum#215',
+                        ],
+                        'relationships' => [
+                            'discussion' => ['data' => ['id' => 2]],
+                        ],
+                    ],
+                ],
+            ])
+        );
+
+        $this->assertEquals(201, $response->getStatusCode());
+
+        $response = json_decode($response->getBody(), true);
+
+        $this->assertStringNotContainsString('FRANZOFFLARUM$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringContainsString('@franzofflarum#215', $response['data']['attributes']['content']);
+        $this->assertStringNotContainsString('PostMention', $response['data']['attributes']['contentHtml']);
+        $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsPosts);
+    }
+
+    /**
+     * @test
+     */
+    public function mentioning_multiple_posts_works()
+    {
+        $this->app();
+        $this->recalculateDisplayNameDriver();
+
+        $response = $this->send(
+            $this->request('POST', '/api/posts', [
+                'authenticatedAs' => 1,
+                'json' => [
+                    'data' => [
+                        'attributes' => [
+                            'content' => '@toby#5 @flarum @franzofflarum#220 @potato @potato#4',
+                        ],
+                        'relationships' => [
+                            'discussion' => ['data' => ['id' => 2]],
+                        ],
+                    ],
+                ],
+            ])
+        );
+
+        $this->assertEquals(201, $response->getStatusCode());
+
+        $response = json_decode($response->getBody(), true);
+
+        $this->assertStringContainsString('TOBY$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringNotContainsString('FRANZOFFLARUM$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringContainsString('POTATO$', $response['data']['attributes']['contentHtml']);
+        $this->assertEquals('@toby#5 @flarum @franzofflarum#220 @potato @potato#4', $response['data']['attributes']['content']);
+        $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
+        $this->assertCount(2, CommentPost::find($response['data']['id'])->mentionsPosts);
+    }
+
+    /**
+     * @test
+     */
+    public function post_mentions_render_with_fresh_data()
+    {
+        $this->app();
+        $this->recalculateDisplayNameDriver();
+
+        $response = $this->send(
+            $this->request('GET', '/api/posts/4', [
+                'authenticatedAs' => 1,
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $response = json_decode($response->getBody(), true);
+
+        $this->assertStringContainsString('TOBY$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringContainsString('PostMention', $response['data']['attributes']['contentHtml']);
+        $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsPosts);
+    }
+}
+
+class CustomOtherDisplayNameDriver implements DriverInterface
+{
+    public function displayName(User $user): string
+    {
+        return strtoupper($user->username).'$';
+    }
+}
diff --git a/extensions/mentions/tests/integration/api/UserMentionsTest.php b/extensions/mentions/tests/integration/api/UserMentionsTest.php
new file mode 100644
index 000000000..a23de7498
--- /dev/null
+++ b/extensions/mentions/tests/integration/api/UserMentionsTest.php
@@ -0,0 +1,206 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Mentions\Tests\integration\api;
+
+use Carbon\Carbon;
+use Flarum\Extend;
+use Flarum\Post\CommentPost;
+use Flarum\Testing\integration\RetrievesAuthorizedUsers;
+use Flarum\Testing\integration\TestCase;
+use Flarum\Testing\integration\UsesSettings;
+use Flarum\User\DisplayName\DriverInterface;
+use Flarum\User\User;
+
+class UserMentionsTest extends TestCase
+{
+    use RetrievesAuthorizedUsers;
+    use UsesSettings;
+
+    /**
+     * @inheritDoc
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->extension('flarum-mentions');
+
+        $this->prepareDatabase([
+            'users' => [
+                $this->normalUser(),
+                ['id' => 3, 'username' => 'potato', 'email' => 'potato@machine.local', 'is_email_confirmed' => 1],
+                ['id' => 4, 'username' => 'toby', 'email' => 'toby@machine.local', 'is_email_confirmed' => 1],
+            ],
+            'discussions' => [
+                ['id' => 2, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 3, 'first_post_id' => 4, 'comment_count' => 2],
+            ],
+            'posts' => [
+                ['id' => 4, 'number' => 2, 'discussion_id' => 2, 'created_at' => Carbon::now(), 'user_id' => 3, 'type' => 'comment', 'content' => '<r><USERMENTION displayname="TobyFlarum___" id="4" username="toby">@tobyuuu</USERMENTION></r>'],
+            ],
+            'post_mentions_user' => [
+                ['post_id' => 4, 'mentions_user_id' => 4]
+            ],
+            'settings' => [
+                ['key' => 'display_name_driver', 'value' => 'custom_display_name_driver'],
+            ],
+        ]);
+
+        $this->extend(
+            (new Extend\User)
+                ->displayNameDriver('custom_display_name_driver', CustomDisplayNameDriver::class)
+        );
+    }
+
+    /**
+     * Purge the settings cache and reset the new display name driver.
+     */
+    protected function recalculateDisplayNameDriver()
+    {
+        $this->purgeSettingsCache();
+        $container = $this->app()->getContainer();
+        $container->forgetInstance('flarum.user.display_name.driver');
+        User::setDisplayNameDriver($container->make('flarum.user.display_name.driver'));
+    }
+
+    /**
+     * @test
+     */
+    public function mentioning_a_valid_user_works()
+    {
+        $this->app();
+        $this->recalculateDisplayNameDriver();
+
+        $response = $this->send(
+            $this->request('POST', '/api/posts', [
+                'authenticatedAs' => 2,
+                'json' => [
+                    'data' => [
+                        'attributes' => [
+                            'content' => '@potato',
+                        ],
+                        'relationships' => [
+                            'discussion' => ['data' => ['id' => 2]],
+                        ],
+                    ],
+                ],
+            ])
+        );
+
+        $this->assertEquals(201, $response->getStatusCode());
+
+        $response = json_decode($response->getBody(), true);
+
+        $this->assertStringContainsString('@POTATO$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringContainsString('@potato', $response['data']['attributes']['content']);
+        $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
+        $this->assertNotNull(CommentPost::find($response['data']['id'])->mentionsUsers->find(3));
+    }
+
+    /**
+     * @test
+     */
+    public function mentioning_an_invalid_user_doesnt_work()
+    {
+        $this->app();
+        $this->recalculateDisplayNameDriver();
+
+        $response = $this->send(
+            $this->request('POST', '/api/posts', [
+                'authenticatedAs' => 2,
+                'json' => [
+                    'data' => [
+                        'attributes' => [
+                            'content' => '@franzofflarum',
+                        ],
+                        'relationships' => [
+                            'discussion' => ['data' => ['id' => 2]],
+                        ],
+                    ],
+                ],
+            ])
+        );
+
+        $this->assertEquals(201, $response->getStatusCode());
+
+        $response = json_decode($response->getBody(), true);
+
+        $this->assertStringNotContainsString('@FRANZOFFLARUM$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringContainsString('@franzofflarum', $response['data']['attributes']['content']);
+        $this->assertStringNotContainsString('UserMention', $response['data']['attributes']['contentHtml']);
+        $this->assertCount(0, CommentPost::find($response['data']['id'])->mentionsUsers);
+    }
+
+    /**
+     * @test
+     */
+    public function mentioning_multiple_users_works()
+    {
+        $this->app();
+        $this->recalculateDisplayNameDriver();
+
+        $response = $this->send(
+            $this->request('POST', '/api/posts', [
+                'authenticatedAs' => 2,
+                'json' => [
+                    'data' => [
+                        'attributes' => [
+                            'content' => '@toby @potato#4 @franzofflarum @potato',
+                        ],
+                        'relationships' => [
+                            'discussion' => ['data' => ['id' => 2]],
+                        ],
+                    ],
+                ],
+            ])
+        );
+
+        $this->assertEquals(201, $response->getStatusCode());
+
+        $response = json_decode($response->getBody(), true);
+
+        $this->assertStringContainsString('@TOBY$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringNotContainsString('@FRANZOFFLARUM$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringContainsString('@POTATO$', $response['data']['attributes']['contentHtml']);
+        $this->assertEquals('@toby @potato#4 @franzofflarum @potato', $response['data']['attributes']['content']);
+        $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
+        $this->assertCount(2, CommentPost::find($response['data']['id'])->mentionsUsers);
+    }
+
+    /**
+     * @test
+     */
+    public function user_mentions_render_with_fresh_data()
+    {
+        $this->app();
+        $this->recalculateDisplayNameDriver();
+
+        $response = $this->send(
+            $this->request('GET', '/api/posts/4', [
+                'authenticatedAs' => 1,
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+
+        $response = json_decode($response->getBody(), true);
+
+        $this->assertStringContainsString('@TOBY$', $response['data']['attributes']['contentHtml']);
+        $this->assertStringContainsString('UserMention', $response['data']['attributes']['contentHtml']);
+        $this->assertCount(1, CommentPost::find($response['data']['id'])->mentionsUsers);
+    }
+}
+
+class CustomDisplayNameDriver implements DriverInterface
+{
+    public function displayName(User $user): string
+    {
+        return strtoupper($user->username).'$';
+    }
+}
diff --git a/extensions/mentions/tests/integration/setup.php b/extensions/mentions/tests/integration/setup.php
new file mode 100644
index 000000000..67039c083
--- /dev/null
+++ b/extensions/mentions/tests/integration/setup.php
@@ -0,0 +1,16 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+use Flarum\Testing\integration\Setup\SetupScript;
+
+require __DIR__.'/../../vendor/autoload.php';
+
+$setup = new SetupScript();
+
+$setup->run();
diff --git a/extensions/mentions/tests/phpunit.integration.xml b/extensions/mentions/tests/phpunit.integration.xml
new file mode 100644
index 000000000..23afc237d
--- /dev/null
+++ b/extensions/mentions/tests/phpunit.integration.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
+    backupGlobals="false"
+    backupStaticAttributes="false"
+    colors="true"
+    convertErrorsToExceptions="true"
+    convertNoticesToExceptions="true"
+    convertWarningsToExceptions="true"
+    processIsolation="true"
+    stopOnFailure="false"
+>
+    <coverage processUncoveredFiles="true">
+        <include>
+            <directory suffix=".php">../src/</directory>
+        </include>
+    </coverage>
+    <testsuites>
+        <testsuite name="Flarum Integration Tests">
+            <directory suffix="Test.php">./integration</directory>
+        </testsuite>
+    </testsuites>
+</phpunit>
diff --git a/extensions/mentions/tests/phpunit.unit.xml b/extensions/mentions/tests/phpunit.unit.xml
new file mode 100644
index 000000000..d3a4a3e3d
--- /dev/null
+++ b/extensions/mentions/tests/phpunit.unit.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
+    backupGlobals="false"
+    backupStaticAttributes="false"
+    colors="true"
+    convertErrorsToExceptions="true"
+    convertNoticesToExceptions="true"
+    convertWarningsToExceptions="true"
+    processIsolation="false"
+    stopOnFailure="false"
+>
+    <coverage processUncoveredFiles="true">
+        <include>
+            <directory suffix=".php">../src/</directory>
+        </include>
+    </coverage>
+    <testsuites>
+        <testsuite name="Flarum Unit Tests">
+            <directory suffix="Test.php">./unit</directory>
+        </testsuite>
+    </testsuites>
+    <listeners>
+        <listener class="\Mockery\Adapter\Phpunit\TestListener" />
+    </listeners>
+</phpunit>
diff --git a/extensions/mentions/tests/unit/.gitkeep b/extensions/mentions/tests/unit/.gitkeep
new file mode 100644
index 000000000..e69de29bb