diff --git a/js/src/admin/components/BasicsPage.js b/js/src/admin/components/BasicsPage.js
index edf2c5907..c2b6175af 100644
--- a/js/src/admin/components/BasicsPage.js
+++ b/js/src/admin/components/BasicsPage.js
@@ -25,10 +25,6 @@ export default class BasicsPage extends Page {
       'welcome_message',
       'display_name_driver',
     ];
-    this.values = {};
-
-    const settings = app.data.settings;
-    this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
 
     this.localeOptions = {};
     const locales = app.data.locales;
@@ -42,8 +38,29 @@ export default class BasicsPage extends Page {
       this.displayNameOptions[identifier] = identifier;
     }, this);
 
+    this.slugDriverOptions = {};
+    Object.keys(app.data.slugDrivers).forEach((model) => {
+      this.fields.push(`slug_driver_${model}`);
+      this.slugDriverOptions[model] = {};
+
+      app.data.slugDrivers[model].forEach((option) => {
+        this.slugDriverOptions[model][option] = option;
+      });
+    });
+
+    this.values = {};
+
+    const settings = app.data.settings;
+    this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
+
     if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
 
+    Object.keys(app.data.slugDrivers).forEach((model) => {
+      if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
+        this.values[`slug_driver_${model}`]('default');
+      }
+    });
+
     if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
   }
 
@@ -132,20 +149,30 @@ export default class BasicsPage extends Page {
               ]
             )}
 
-            {Object.keys(this.displayNameOptions).length > 1
-              ? FieldSet.component(
-                  {
-                    label: app.translator.trans('core.admin.basics.display_name_heading'),
-                  },
-                  [
-                    <div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
-                    Select.component({
-                      options: this.displayNameOptions,
-                      bidi: this.values.display_name_driver,
-                    }),
-                  ]
-                )
-              : ''}
+            {Object.keys(this.displayNameOptions).length > 1 ? (
+              <FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
+                <div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
+                <Select
+                  options={this.displayNameOptions}
+                  value={this.values.display_name_driver()}
+                  onchange={this.values.display_name_driver}
+                ></Select>
+              </FieldSet>
+            ) : (
+              ''
+            )}
+
+            {Object.keys(this.slugDriverOptions).map((model) => {
+              const options = this.slugDriverOptions[model];
+              if (Object.keys(options).length > 1) {
+                return (
+                  <FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
+                    <div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
+                    <Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
+                  </FieldSet>
+                );
+              }
+            })}
 
             {Button.component(
               {
diff --git a/js/src/common/models/User.js b/js/src/common/models/User.js
index 54d8f5071..30bc0567b 100644
--- a/js/src/common/models/User.js
+++ b/js/src/common/models/User.js
@@ -10,6 +10,7 @@ export default class User extends Model {}
 
 Object.assign(User.prototype, {
   username: Model.attribute('username'),
+  slug: Model.attribute('slug'),
   displayName: Model.attribute('displayName'),
   email: Model.attribute('email'),
   isEmailConfirmed: Model.attribute('isEmailConfirmed'),
diff --git a/js/src/forum/components/DiscussionListItem.js b/js/src/forum/components/DiscussionListItem.js
index ecd6a6c85..5fddbf950 100644
--- a/js/src/forum/components/DiscussionListItem.js
+++ b/js/src/forum/components/DiscussionListItem.js
@@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
 import slidable from '../utils/slidable';
 import extractText from '../../common/utils/extractText';
 import classList from '../../common/utils/classList';
+import DiscussionPage from './DiscussionPage';
 
 import { escapeRegExp } from 'lodash-es';
 /**
@@ -156,9 +157,7 @@ export default class DiscussionListItem extends Component {
    * @return {Boolean}
    */
   active() {
-    const idParam = m.route.param('id');
-
-    return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
+    return app.current.matches(DiscussionPage, { discussion: this.attrs.discussion });
   }
 
   /**
diff --git a/js/src/forum/components/DiscussionPage.js b/js/src/forum/components/DiscussionPage.js
index beb0b2a5d..80dd5e8e0 100644
--- a/js/src/forum/components/DiscussionPage.js
+++ b/js/src/forum/components/DiscussionPage.js
@@ -109,7 +109,7 @@ export default class DiscussionPage extends Page {
     } else {
       const params = this.requestParams();
 
-      app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
+      app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
     }
 
     m.redraw();
@@ -123,6 +123,7 @@ export default class DiscussionPage extends Page {
    */
   requestParams() {
     return {
+      bySlug: true,
       page: { near: this.near },
     };
   }
diff --git a/js/src/forum/components/Search.js b/js/src/forum/components/Search.js
index 451afbf81..9698c58df 100644
--- a/js/src/forum/components/Search.js
+++ b/js/src/forum/components/Search.js
@@ -21,6 +21,8 @@ import UsersSearchSource from './UsersSearchSource';
  * - state: SearchState instance.
  */
 export default class Search extends Component {
+  static MIN_SEARCH_LEN = 3;
+
   oninit(vnode) {
     super.oninit(vnode);
     this.state = this.attrs.state;
@@ -152,7 +154,7 @@ export default class Search extends Component {
         search.searchTimeout = setTimeout(() => {
           if (state.isCached(query)) return;
 
-          if (query.length >= 3) {
+          if (query.length >= Search.MIN_SEARCH_LEN) {
             search.sources.map((source) => {
               if (!source.search) return;
 
diff --git a/js/src/forum/components/UserPage.js b/js/src/forum/components/UserPage.js
index 66bc22db6..37e950164 100644
--- a/js/src/forum/components/UserPage.js
+++ b/js/src/forum/components/UserPage.js
@@ -102,7 +102,7 @@ export default class UserPage extends Page {
     });
 
     if (!this.user) {
-      app.store.find('users', username).then(this.show.bind(this));
+      app.store.find('users', username, { bySlug: true }).then(this.show.bind(this));
     }
   }
 
diff --git a/js/src/forum/resolvers/DiscussionPageResolver.ts b/js/src/forum/resolvers/DiscussionPageResolver.ts
index 80641c7c0..d208aa169 100644
--- a/js/src/forum/resolvers/DiscussionPageResolver.ts
+++ b/js/src/forum/resolvers/DiscussionPageResolver.ts
@@ -1,15 +1,6 @@
 import DefaultResolver from '../../common/resolvers/DefaultResolver';
 import DiscussionPage from '../components/DiscussionPage';
 
-/**
- * This isn't exported as it is a temporary measure.
- * A more robust system will be implemented alongside UTF-8 support in beta 15.
- */
-function getDiscussionIdFromSlug(slug: string | undefined) {
-  if (!slug) return;
-  return slug.split('-')[0];
-}
-
 /**
  * A custom route resolver for DiscussionPage that generates the same key to all posts
  * on the same discussion. It triggers a scroll when going from one post to another
@@ -18,17 +9,32 @@ function getDiscussionIdFromSlug(slug: string | undefined) {
 export default class DiscussionPageResolver extends DefaultResolver {
   static scrollToPostNumber: string | null = null;
 
+  /**
+   * Remove optional parts of a discussion's slug to keep the substring
+   * that bijectively maps to a discussion object. By default this just
+   * extracts the numerical ID from the slug. If a custom discussion
+   * slugging driver is used, this may need to be overriden.
+   * @param slug
+   */
+  canonicalizeDiscussionSlug(slug: string | undefined) {
+    if (!slug) return;
+    return slug.split('-')[0];
+  }
+
+  /**
+   * @inheritdoc
+   */
   makeKey() {
     const params = { ...m.route.param() };
     if ('near' in params) {
       delete params.near;
     }
-    params.id = getDiscussionIdFromSlug(params.id);
+    params.id = this.canonicalizeDiscussionSlug(params.id);
     return this.routeName.replace('.near', '') + JSON.stringify(params);
   }
 
   onmatch(args, requestedPath, route) {
-    if (app.current.matches(DiscussionPage) && getDiscussionIdFromSlug(args.id) === getDiscussionIdFromSlug(m.route.param('id'))) {
+    if (app.current.matches(DiscussionPage) && this.canonicalizeDiscussionSlug(args.id) === this.canonicalizeDiscussionSlug(m.route.param('id'))) {
       // By default, the first post number of any discussion is 1
       DiscussionPageResolver.scrollToPostNumber = args.near || '1';
     }
diff --git a/js/src/forum/routes.js b/js/src/forum/routes.js
index 9615b4ee8..6f10c27c5 100644
--- a/js/src/forum/routes.js
+++ b/js/src/forum/routes.js
@@ -34,9 +34,8 @@ export default function (app) {
    * @return {String}
    */
   app.route.discussion = (discussion, near) => {
-    const slug = discussion.slug();
     return app.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
-      id: discussion.id() + (slug.trim() ? '-' + slug : ''),
+      id: discussion.slug(),
       near: near && near !== 1 ? near : undefined,
     });
   };
@@ -59,7 +58,7 @@ export default function (app) {
    */
   app.route.user = (user) => {
     return app.route('user', {
-      username: user.username(),
+      username: user.slug(),
     });
   };
 }
diff --git a/src/Admin/Content/AdminPayload.php b/src/Admin/Content/AdminPayload.php
index e766c072a..21347271e 100644
--- a/src/Admin/Content/AdminPayload.php
+++ b/src/Admin/Content/AdminPayload.php
@@ -75,6 +75,9 @@ class AdminPayload
         $document->payload['extensions'] = $this->extensions->getExtensions()->toArray();
 
         $document->payload['displayNameDrivers'] = array_keys($this->container->make('flarum.user.display_name.supported_drivers'));
+        $document->payload['slugDrivers'] = array_map(function ($resourceDrivers) {
+            return array_keys($resourceDrivers);
+        }, $this->container->make('flarum.http.slugDrivers'));
 
         $document->payload['phpVersion'] = PHP_VERSION;
         $document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version;
diff --git a/src/Api/Controller/ShowDiscussionController.php b/src/Api/Controller/ShowDiscussionController.php
index b9fb36f7b..76710d7ed 100644
--- a/src/Api/Controller/ShowDiscussionController.php
+++ b/src/Api/Controller/ShowDiscussionController.php
@@ -12,6 +12,7 @@ namespace Flarum\Api\Controller;
 use Flarum\Api\Serializer\DiscussionSerializer;
 use Flarum\Discussion\Discussion;
 use Flarum\Discussion\DiscussionRepository;
+use Flarum\Http\SlugManager;
 use Flarum\Post\PostRepository;
 use Flarum\User\User;
 use Illuminate\Support\Arr;
@@ -31,6 +32,11 @@ class ShowDiscussionController extends AbstractShowController
      */
     protected $posts;
 
+    /**
+     * @var SlugManager
+     */
+    protected $slugManager;
+
     /**
      * {@inheritdoc}
      */
@@ -61,11 +67,13 @@ class ShowDiscussionController extends AbstractShowController
     /**
      * @param \Flarum\Discussion\DiscussionRepository $discussions
      * @param \Flarum\Post\PostRepository $posts
+     * @param \Flarum\Http\SlugManager $slugManager
      */
-    public function __construct(DiscussionRepository $discussions, PostRepository $posts)
+    public function __construct(DiscussionRepository $discussions, PostRepository $posts, SlugManager $slugManager)
     {
         $this->discussions = $discussions;
         $this->posts = $posts;
+        $this->slugManager = $slugManager;
     }
 
     /**
@@ -77,7 +85,11 @@ class ShowDiscussionController extends AbstractShowController
         $actor = $request->getAttribute('actor');
         $include = $this->extractInclude($request);
 
-        $discussion = $this->discussions->findOrFail($discussionId, $actor);
+        if (Arr::get($request->getQueryParams(), 'bySlug', false)) {
+            $discussion = $this->slugManager->forResource(Discussion::class)->fromSlug($discussionId, $actor);
+        } else {
+            $discussion = $this->discussions->findOrFail($discussionId, $actor);
+        }
 
         if (in_array('posts', $include)) {
             $postRelationships = $this->getPostRelationships($include);
diff --git a/src/Api/Controller/ShowUserController.php b/src/Api/Controller/ShowUserController.php
index 8f3826e55..60412362a 100644
--- a/src/Api/Controller/ShowUserController.php
+++ b/src/Api/Controller/ShowUserController.php
@@ -11,6 +11,8 @@ namespace Flarum\Api\Controller;
 
 use Flarum\Api\Serializer\CurrentUserSerializer;
 use Flarum\Api\Serializer\UserSerializer;
+use Flarum\Http\SlugManager;
+use Flarum\User\User;
 use Flarum\User\UserRepository;
 use Illuminate\Support\Arr;
 use Psr\Http\Message\ServerRequestInterface;
@@ -29,15 +31,22 @@ class ShowUserController extends AbstractShowController
     public $include = ['groups'];
 
     /**
-     * @var \Flarum\User\UserRepository
+     * @var SlugManager
+     */
+    protected $slugManager;
+
+    /**
+     * @var UserRepository
      */
     protected $users;
 
     /**
-     * @param \Flarum\User\UserRepository $users
+     * @param SlugManager $slugManager
+     * @param UserRepository $users
      */
-    public function __construct(UserRepository $users)
+    public function __construct(SlugManager $slugManager, UserRepository $users)
     {
+        $this->slugManager = $slugManager;
         $this->users = $users;
     }
 
@@ -47,17 +56,18 @@ class ShowUserController extends AbstractShowController
     protected function data(ServerRequestInterface $request, Document $document)
     {
         $id = Arr::get($request->getQueryParams(), 'id');
-
-        if (! is_numeric($id)) {
-            $id = $this->users->getIdForUsername($id);
-        }
-
         $actor = $request->getAttribute('actor');
 
-        if ($actor->id == $id) {
+        if (Arr::get($request->getQueryParams(), 'bySlug', false)) {
+            $user = $this->slugManager->forResource(User::class)->fromSlug($id, $actor);
+        } else {
+            $user = $this->users->findOrFail($id, $actor);
+        }
+
+        if ($actor->id === $user->id) {
             $this->serializer = CurrentUserSerializer::class;
         }
 
-        return $this->users->findOrFail($id, $actor);
+        return $user;
     }
 }
diff --git a/src/Api/Serializer/BasicDiscussionSerializer.php b/src/Api/Serializer/BasicDiscussionSerializer.php
index cdbf491c5..64bbca752 100644
--- a/src/Api/Serializer/BasicDiscussionSerializer.php
+++ b/src/Api/Serializer/BasicDiscussionSerializer.php
@@ -10,6 +10,7 @@
 namespace Flarum\Api\Serializer;
 
 use Flarum\Discussion\Discussion;
+use Flarum\Http\SlugManager;
 use InvalidArgumentException;
 
 class BasicDiscussionSerializer extends AbstractSerializer
@@ -19,6 +20,16 @@ class BasicDiscussionSerializer extends AbstractSerializer
      */
     protected $type = 'discussions';
 
+    /**
+     * @var SlugManager
+     */
+    protected $slugManager;
+
+    public function __construct(SlugManager $slugManager)
+    {
+        $this->slugManager = $slugManager;
+    }
+
     /**
      * {@inheritdoc}
      *
@@ -35,7 +46,7 @@ class BasicDiscussionSerializer extends AbstractSerializer
 
         return [
             'title' => $discussion->title,
-            'slug'  => $discussion->slug,
+            'slug' =>  $this->slugManager->forResource(Discussion::class)->toSlug($discussion),
         ];
     }
 
diff --git a/src/Api/Serializer/BasicUserSerializer.php b/src/Api/Serializer/BasicUserSerializer.php
index 6fb6c24d0..e024d338d 100644
--- a/src/Api/Serializer/BasicUserSerializer.php
+++ b/src/Api/Serializer/BasicUserSerializer.php
@@ -9,6 +9,7 @@
 
 namespace Flarum\Api\Serializer;
 
+use Flarum\Http\SlugManager;
 use Flarum\User\User;
 use InvalidArgumentException;
 
@@ -19,6 +20,16 @@ class BasicUserSerializer extends AbstractSerializer
      */
     protected $type = 'users';
 
+    /**
+     * @var SlugManager
+     */
+    protected $slugManager;
+
+    public function __construct(SlugManager $slugManager)
+    {
+        $this->slugManager = $slugManager;
+    }
+
     /**
      * {@inheritdoc}
      *
@@ -36,7 +47,8 @@ class BasicUserSerializer extends AbstractSerializer
         return [
             'username'    => $user->username,
             'displayName' => $user->display_name,
-            'avatarUrl'   => $user->avatar_url
+            'avatarUrl'   => $user->avatar_url,
+            'slug'        => $this->slugManager->forResource(User::class)->toSlug($user)
         ];
     }
 
diff --git a/src/Discussion/IdWithTransliteratedSlugDriver.php b/src/Discussion/IdWithTransliteratedSlugDriver.php
new file mode 100644
index 000000000..3a1cb58ef
--- /dev/null
+++ b/src/Discussion/IdWithTransliteratedSlugDriver.php
@@ -0,0 +1,42 @@
+<?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\Discussion;
+
+use Flarum\Database\AbstractModel;
+use Flarum\Http\SlugDriverInterface;
+use Flarum\User\User;
+
+class IdWithTransliteratedSlugDriver implements SlugDriverInterface
+{
+    /**
+     * @var DiscussionRepository
+     */
+    protected $discussions;
+
+    public function __construct(DiscussionRepository $discussions)
+    {
+        $this->discussions = $discussions;
+    }
+
+    public function toSlug(AbstractModel $instance): string
+    {
+        return $instance->id.(trim($instance->slug) ? '-'.$instance->slug : '');
+    }
+
+    public function fromSlug(string $slug, User $actor): AbstractModel
+    {
+        if (strpos($slug, '-')) {
+            $slug_array = explode('-', $slug);
+            $slug = $slug_array[0];
+        }
+
+        return $this->discussions->findOrFail($slug, $actor);
+    }
+}
diff --git a/src/Extend/ModelUrl.php b/src/Extend/ModelUrl.php
new file mode 100644
index 000000000..7c410dfd6
--- /dev/null
+++ b/src/Extend/ModelUrl.php
@@ -0,0 +1,54 @@
+<?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\Extend;
+
+use Flarum\Extension\Extension;
+use Illuminate\Contracts\Container\Container;
+use Illuminate\Support\Arr;
+
+class ModelUrl implements ExtenderInterface
+{
+    private $modelClass;
+    private $slugDrivers = [];
+
+    /**
+     * @param string $modelClass The ::class attribute of the model you are modifying.
+     *                           This model should extend from \Flarum\Database\AbstractModel.
+     */
+    public function __construct(string $modelClass)
+    {
+        $this->modelClass = $modelClass;
+    }
+
+    /**
+     * Add a slug driver.
+     *
+     * @param string $identifier Identifier for slug driver.
+     * @param string $driver ::class attribute of driver class, which must implement Flarum\Http\SlugDriverInterface
+     * @return self
+     */
+    public function addSlugDriver(string $identifier, string $driver)
+    {
+        $this->slugDrivers[$identifier] = $driver;
+
+        return $this;
+    }
+
+    public function extend(Container $container, Extension $extension = null)
+    {
+        if ($this->slugDrivers) {
+            $container->extend('flarum.http.slugDrivers', function ($existingDrivers) {
+                $existingDrivers[$this->modelClass] = array_merge(Arr::get($existingDrivers, $this->modelClass, []), $this->slugDrivers);
+
+                return $existingDrivers;
+            });
+        }
+    }
+}
diff --git a/src/Forum/Content/Discussion.php b/src/Forum/Content/Discussion.php
index 748027383..0fc9b72e4 100644
--- a/src/Forum/Content/Discussion.php
+++ b/src/Forum/Content/Discussion.php
@@ -74,9 +74,7 @@ class Discussion
             unset($newQueryParams['id']);
             $queryString = http_build_query($newQueryParams);
 
-            $idWithSlug = $apiDocument->data->id.(trim($apiDocument->data->attributes->slug) ? '-'.$apiDocument->data->attributes->slug : '');
-
-            return $this->url->to('forum')->route('discussion', ['id' => $idWithSlug]).
+            return $this->url->to('forum')->route('discussion', ['id' => $apiDocument->data->attributes->slug]).
             ($queryString ? '?'.$queryString : '');
         };
 
@@ -106,6 +104,7 @@ class Discussion
      */
     protected function getApiDocument(User $actor, array $params)
     {
+        $params['bySlug'] = true;
         $response = $this->api->send('Flarum\Api\Controller\ShowDiscussionController', $actor, $params);
         $statusCode = $response->getStatusCode();
 
diff --git a/src/Forum/Content/User.php b/src/Forum/Content/User.php
index 26a6764a6..f1995f03d 100644
--- a/src/Forum/Content/User.php
+++ b/src/Forum/Content/User.php
@@ -54,7 +54,7 @@ class User
         $user = $apiDocument->data->attributes;
 
         $document->title = $user->displayName;
-        $document->canonicalUrl = $this->url->to('forum')->route('user', ['username' => $user->username]);
+        $document->canonicalUrl = $this->url->to('forum')->route('user', ['username' => $user->slug]);
         $document->payload['apiDocument'] = $apiDocument;
 
         return $document;
@@ -70,6 +70,7 @@ class User
      */
     protected function getApiDocument(FlarumUser $actor, array $params)
     {
+        $params['bySlug'] = true;
         $response = $this->api->send(ShowUserController::class, $actor, $params);
         $statusCode = $response->getStatusCode();
 
diff --git a/src/Http/HttpServiceProvider.php b/src/Http/HttpServiceProvider.php
index 4a7d71b24..0fd218200 100644
--- a/src/Http/HttpServiceProvider.php
+++ b/src/Http/HttpServiceProvider.php
@@ -9,7 +9,13 @@
 
 namespace Flarum\Http;
 
+use Flarum\Discussion\Discussion;
+use Flarum\Discussion\IdWithTransliteratedSlugDriver;
 use Flarum\Foundation\AbstractServiceProvider;
+use Flarum\Settings\SettingsRepositoryInterface;
+use Flarum\User\User;
+use Flarum\User\UsernameSlugDriver;
+use Illuminate\Support\Arr;
 
 class HttpServiceProvider extends AbstractServiceProvider
 {
@@ -25,5 +31,35 @@ class HttpServiceProvider extends AbstractServiceProvider
         $this->app->bind(Middleware\CheckCsrfToken::class, function ($app) {
             return new Middleware\CheckCsrfToken($app->make('flarum.http.csrfExemptPaths'));
         });
+
+        $this->app->singleton('flarum.http.slugDrivers', function () {
+            return [
+                Discussion::class => [
+                    'default' => IdWithTransliteratedSlugDriver::class
+                ],
+                User::class => [
+                    'default' => UsernameSlugDriver::class
+                ],
+            ];
+        });
+
+        $this->app->singleton('flarum.http.selectedSlugDrivers', function () {
+            $settings = $this->app->make(SettingsRepositoryInterface::class);
+
+            $compiledDrivers = [];
+
+            foreach ($this->app->make('flarum.http.slugDrivers') as $resourceClass => $resourceDrivers) {
+                $driverKey = $settings->get("slug_driver_$resourceClass", 'default');
+
+                $driverClass = Arr::get($resourceDrivers, $driverKey, $resourceDrivers['default']);
+
+                $compiledDrivers[$resourceClass] = $this->app->make($driverClass);
+            }
+
+            return $compiledDrivers;
+        });
+        $this->app->bind(SlugManager::class, function () {
+            return new SlugManager($this->app->make('flarum.http.selectedSlugDrivers'));
+        });
     }
 }
diff --git a/src/Http/SlugDriverInterface.php b/src/Http/SlugDriverInterface.php
new file mode 100644
index 000000000..84d3a621e
--- /dev/null
+++ b/src/Http/SlugDriverInterface.php
@@ -0,0 +1,20 @@
+<?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\Http;
+
+use Flarum\Database\AbstractModel;
+use Flarum\User\User;
+
+interface SlugDriverInterface
+{
+    public function toSlug(AbstractModel $instance): string;
+
+    public function fromSlug(string $slug, User $actor): AbstractModel;
+}
diff --git a/src/Http/SlugManager.php b/src/Http/SlugManager.php
new file mode 100644
index 000000000..71ec28a85
--- /dev/null
+++ b/src/Http/SlugManager.php
@@ -0,0 +1,27 @@
+<?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\Http;
+
+use Illuminate\Support\Arr;
+
+class SlugManager
+{
+    protected $drivers = [];
+
+    public function __construct(array $drivers)
+    {
+        $this->drivers = $drivers;
+    }
+
+    public function forResource(string $resourceName): SlugDriverInterface
+    {
+        return Arr::get($this->drivers, $resourceName, null);
+    }
+}
diff --git a/src/User/UserRepository.php b/src/User/UserRepository.php
index b37542a70..eb828e37a 100644
--- a/src/User/UserRepository.php
+++ b/src/User/UserRepository.php
@@ -40,6 +40,23 @@ class UserRepository
         return $this->scopeVisibleTo($query, $actor)->firstOrFail();
     }
 
+    /**
+     * Find a user by username, optionally making sure it is visible to a certain
+     * user, or throw an exception.
+     *
+     * @param int $id
+     * @param User $actor
+     * @return User
+     *
+     * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+     */
+    public function findOrFailByUsername($username, User $actor = null)
+    {
+        $query = User::where('username', $username);
+
+        return $this->scopeVisibleTo($query, $actor)->firstOrFail();
+    }
+
     /**
      * Find a user by an identification (username or email).
      *
diff --git a/src/User/UsernameSlugDriver.php b/src/User/UsernameSlugDriver.php
new file mode 100644
index 000000000..9bacf3740
--- /dev/null
+++ b/src/User/UsernameSlugDriver.php
@@ -0,0 +1,36 @@
+<?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\User;
+
+use Flarum\Database\AbstractModel;
+use Flarum\Http\SlugDriverInterface;
+
+class UsernameSlugDriver implements SlugDriverInterface
+{
+    /**
+     * @var UserRepository
+     */
+    protected $users;
+
+    public function __construct(UserRepository $users)
+    {
+        $this->users = $users;
+    }
+
+    public function toSlug(AbstractModel $instance): string
+    {
+        return $instance->username;
+    }
+
+    public function fromSlug(string $slug, User $actor): AbstractModel
+    {
+        return $this->users->findOrFailByUsername($slug, $actor);
+    }
+}
diff --git a/tests/integration/api/discussions/ShowTest.php b/tests/integration/api/discussions/ShowTest.php
index 131cd6776..b4300a34a 100644
--- a/tests/integration/api/discussions/ShowTest.php
+++ b/tests/integration/api/discussions/ShowTest.php
@@ -66,6 +66,24 @@ class ShowTest extends TestCase
         $this->assertEquals(200, $response->getStatusCode());
     }
 
+    /**
+     * @test
+     */
+    public function author_can_see_discussion_via_slug()
+    {
+        // Note that here, the slug doesn't actually have to match the real slug
+        // since the default slugging strategy only takes the numerical part into account
+        $response = $this->send(
+            $this->request('GET', '/api/discussions/1-fdsafdsajfsakf', [
+                'authenticatedAs' => 2,
+            ])->withQueryParams([
+                'bySlug' => true
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
     /**
      * @test
      */
diff --git a/tests/integration/api/users/CreateTest.php b/tests/integration/api/users/CreateTest.php
index c6d7bd198..65e821ab7 100644
--- a/tests/integration/api/users/CreateTest.php
+++ b/tests/integration/api/users/CreateTest.php
@@ -25,9 +25,10 @@ class CreateTest extends TestCase
         $this->prepareDatabase([
             'users' => [
                 $this->adminUser(),
+                $this->normalUser(),
             ],
             'groups' => [
-                $this->adminGroup(),
+                $this->adminGroup()
             ],
             'group_user' => [
                 ['user_id' => 1, 'group_id' => 1],
diff --git a/tests/integration/api/users/ShowTest.php b/tests/integration/api/users/ShowTest.php
new file mode 100644
index 000000000..3ee210203
--- /dev/null
+++ b/tests/integration/api/users/ShowTest.php
@@ -0,0 +1,197 @@
+<?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\Tests\integration\api\users;
+
+use Flarum\Tests\integration\RetrievesAuthorizedUsers;
+use Flarum\Tests\integration\TestCase;
+
+class ShowTest extends TestCase
+{
+    use RetrievesAuthorizedUsers;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->prepareDatabase([
+            'users' => [
+                $this->adminUser(),
+                $this->normalUser(),
+            ],
+            'groups' => [
+                $this->adminGroup()
+            ],
+            'group_user' => [
+                ['user_id' => 1, 'group_id' => 1],
+            ],
+            'settings' => [
+                ['key' => 'mail_driver', 'value' => 'log'],
+            ],
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function admin_can_see_user()
+    {
+        $response = $this->send(
+            $this->request('GET', '/api/users/2', [
+                'authenticatedAs' => 1,
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function admin_can_see_user_via_slug()
+    {
+        $response = $this->send(
+            $this->request('GET', '/api/users/normal', [
+                'authenticatedAs' => 1,
+            ])->withQueryParams([
+                'bySlug' => true
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function guest_cannot_see_user()
+    {
+        $response = $this->send(
+            $this->request('GET', '/api/users/2')
+        );
+
+        $this->assertEquals(404, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function guest_cannot_see_user_by_slug()
+    {
+        $response = $this->send(
+            $this->request('GET', '/api/users/2')->withQueryParams([
+                'bySlug' => true
+            ])
+        );
+
+        $this->assertEquals(404, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function user_can_see_themselves()
+    {
+        $response = $this->send(
+            $this->request('GET', '/api/users/2', [
+                'authenticatedAs' => 2,
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function user_can_see_themselves_via_slug()
+    {
+        $response = $this->send(
+            $this->request('GET', '/api/users/normal', [
+                'authenticatedAs' => 2,
+            ])->withQueryParams([
+                'bySlug' => true
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function user_cant_see_others_by_default()
+    {
+        $response = $this->send(
+            $this->request('GET', '/api/users/1', [
+                'authenticatedAs' => 2,
+            ])
+        );
+
+        $this->assertEquals(404, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function user_cant_see_others_by_default_via_slug()
+    {
+        $response = $this->send(
+            $this->request('GET', '/api/users/admin', [
+                'authenticatedAs' => 2,
+            ])->withQueryParams([
+                'bySlug' => true
+            ])
+        );
+
+        $this->assertEquals(404, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function user_can_see_others_if_allowed()
+    {
+        $this->prepareDatabase([
+            'group_permission' => [
+                ['permission' => 'viewDiscussions', 'group_id' => 3],
+            ]
+        ]);
+
+        $response = $this->send(
+            $this->request('GET', '/api/users/1', [
+                'authenticatedAs' => 2,
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
+    /**
+     * @test
+     */
+    public function user_can_see_others_if_allowed_via_slug()
+    {
+        $this->prepareDatabase([
+            'group_permission' => [
+                ['permission' => 'viewDiscussions', 'group_id' => 3],
+            ]
+        ]);
+
+        $response = $this->send(
+            $this->request('GET', '/api/users/admin', [
+                'authenticatedAs' => 2,
+            ])->withQueryParams([
+                'bySlug' => true
+            ])
+        );
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+}
diff --git a/tests/integration/extenders/ModelUrlTest.php b/tests/integration/extenders/ModelUrlTest.php
new file mode 100644
index 000000000..e5eea08ad
--- /dev/null
+++ b/tests/integration/extenders/ModelUrlTest.php
@@ -0,0 +1,82 @@
+<?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\Tests\integration\extenders;
+
+use Flarum\Database\AbstractModel;
+use Flarum\Extend;
+use Flarum\Http\SlugDriverInterface;
+use Flarum\Http\SlugManager;
+use Flarum\Tests\integration\RetrievesAuthorizedUsers;
+use Flarum\Tests\integration\TestCase;
+use Flarum\User\User;
+
+class ModelUrlTest extends TestCase
+{
+    use RetrievesAuthorizedUsers;
+
+    protected function prepDb()
+    {
+        $userClass = User::class;
+        $this->prepareDatabase([
+            'users' => [
+                $this->adminUser(),
+                $this->normalUser(),
+            ],
+            'settings' => [
+                ['key' => "slug_driver_$userClass", 'value' => 'testDriver'],
+            ]
+        ]);
+    }
+
+    /**
+     * @test
+     */
+    public function uses_default_driver_by_default()
+    {
+        $this->prepDb();
+
+        $slugManager = $this->app()->getContainer()->make(SlugManager::class);
+
+        $testUser = User::find(1);
+
+        $this->assertEquals('admin', $slugManager->forResource(User::class)->toSlug($testUser));
+        $this->assertEquals('1', $slugManager->forResource(User::class)->fromSlug('admin', $testUser)->id);
+    }
+
+    /**
+     * @test
+     */
+    public function custom_slug_driver_has_effect_if_added()
+    {
+        $this->extend((new Extend\ModelUrl(User::class))->addSlugDriver('testDriver', TestSlugDriver::class));
+
+        $this->prepDb();
+
+        $slugManager = $this->app()->getContainer()->make(SlugManager::class);
+
+        $testUser = User::find(1);
+
+        $this->assertEquals('test-slug', $slugManager->forResource(User::class)->toSlug($testUser));
+        $this->assertEquals('1', $slugManager->forResource(User::class)->fromSlug('random-gibberish', $testUser)->id);
+    }
+}
+
+class TestSlugDriver implements SlugDriverInterface
+{
+    public function toSlug(AbstractModel $instance): string
+    {
+        return 'test-slug';
+    }
+
+    public function fromSlug(string $slug, User $actor): AbstractModel
+    {
+        return User::find(1);
+    }
+}
diff --git a/views/frontend/content/index.blade.php b/views/frontend/content/index.blade.php
index 809a3bd3a..af938247c 100644
--- a/views/frontend/content/index.blade.php
+++ b/views/frontend/content/index.blade.php
@@ -7,7 +7,7 @@
         @foreach ($apiDocument->data as $discussion)
             <li>
                 <a href="{{ $url->to('forum')->route('discussion', [
-                    'id' => $discussion->id . (trim($discussion->attributes->slug) ? '-' . $discussion->attributes->slug : '')
+                    'id' => $discussion->attributes->slug
                 ]) }}">
                     {{ $discussion->attributes->title }}
                 </a>