diff --git a/app/Access/Notifications/UserInviteNotification.php b/app/Access/Notifications/UserInviteNotification.php
index b453fc95d..dacce574f 100644
--- a/app/Access/Notifications/UserInviteNotification.php
+++ b/app/Access/Notifications/UserInviteNotification.php
@@ -16,12 +16,12 @@ class UserInviteNotification extends MailNotification
     public function toMail(User $notifiable): MailMessage
     {
         $appName = ['appName' => setting('app-name')];
-        $language = $notifiable->getLanguage();
+        $locale = $notifiable->getLocale();
 
-        return $this->newMailMessage($language)
-                ->subject(trans('auth.user_invite_email_subject', $appName, $language))
-                ->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
-                ->line(trans('auth.user_invite_email_text', [], $language))
-                ->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
+        return $this->newMailMessage($locale)
+                ->subject($locale->trans('auth.user_invite_email_subject', $appName))
+                ->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
+                ->line($locale->trans('auth.user_invite_email_text'))
+                ->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
     }
 }
diff --git a/app/Activity/Notifications/Messages/BaseActivityNotification.php b/app/Activity/Notifications/Messages/BaseActivityNotification.php
index 414859091..322df5d94 100644
--- a/app/Activity/Notifications/Messages/BaseActivityNotification.php
+++ b/app/Activity/Notifications/Messages/BaseActivityNotification.php
@@ -5,6 +5,7 @@ namespace BookStack\Activity\Notifications\Messages;
 use BookStack\Activity\Models\Loggable;
 use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
 use BookStack\App\MailNotification;
+use BookStack\Translation\LocaleDefinition;
 use BookStack\Users\Models\User;
 use Illuminate\Bus\Queueable;
 
@@ -35,12 +36,12 @@ abstract class BaseActivityNotification extends MailNotification
     /**
      * Build the common reason footer line used in mail messages.
      */
-    protected function buildReasonFooterLine(string $language): LinkedMailMessageLine
+    protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
     {
         return new LinkedMailMessageLine(
             url('/preferences/notifications'),
-            trans('notifications.footer_reason', [], $language),
-            trans('notifications.footer_reason_link', [], $language),
+            $locale->trans('notifications.footer_reason'),
+            $locale->trans('notifications.footer_reason_link'),
         );
     }
 }
diff --git a/app/Activity/Notifications/Messages/CommentCreationNotification.php b/app/Activity/Notifications/Messages/CommentCreationNotification.php
index 5db61b04b..094ab30b7 100644
--- a/app/Activity/Notifications/Messages/CommentCreationNotification.php
+++ b/app/Activity/Notifications/Messages/CommentCreationNotification.php
@@ -17,17 +17,17 @@ class CommentCreationNotification extends BaseActivityNotification
         /** @var Page $page */
         $page = $comment->entity;
 
-        $language = $notifiable->getLanguage();
+        $locale = $notifiable->getLocale();
 
-        return $this->newMailMessage($language)
-            ->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()], $language))
-            ->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')], $language))
+        return $this->newMailMessage($locale)
+            ->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
+            ->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
             ->line(new ListMessageLine([
-                trans('notifications.detail_page_name', [], $language) => $page->name,
-                trans('notifications.detail_commenter', [], $language) => $this->user->name,
-                trans('notifications.detail_comment', [], $language) => strip_tags($comment->html),
+                $locale->trans('notifications.detail_page_name') => $page->name,
+                $locale->trans('notifications.detail_commenter') => $this->user->name,
+                $locale->trans('notifications.detail_comment') => strip_tags($comment->html),
             ]))
-            ->action(trans('notifications.action_view_comment', [], $language), $page->getUrl('#comment' . $comment->local_id))
-            ->line($this->buildReasonFooterLine($language));
+            ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
+            ->line($this->buildReasonFooterLine($locale));
     }
 }
diff --git a/app/Activity/Notifications/Messages/PageCreationNotification.php b/app/Activity/Notifications/Messages/PageCreationNotification.php
index 110829469..da028aa8c 100644
--- a/app/Activity/Notifications/Messages/PageCreationNotification.php
+++ b/app/Activity/Notifications/Messages/PageCreationNotification.php
@@ -14,16 +14,16 @@ class PageCreationNotification extends BaseActivityNotification
         /** @var Page $page */
         $page = $this->detail;
 
-        $language = $notifiable->getLanguage();
+        $locale = $notifiable->getLocale();
 
-        return $this->newMailMessage($language)
-            ->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()], $language))
-            ->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')], $language))
+        return $this->newMailMessage($locale)
+            ->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
+            ->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')], $locale))
             ->line(new ListMessageLine([
-                trans('notifications.detail_page_name', [], $language) => $page->name,
-                trans('notifications.detail_created_by', [], $language) => $this->user->name,
+                $locale->trans('notifications.detail_page_name') => $page->name,
+                $locale->trans('notifications.detail_created_by') => $this->user->name,
             ]))
-            ->action(trans('notifications.action_view_page', [], $language), $page->getUrl())
-            ->line($this->buildReasonFooterLine($language));
+            ->action($locale->trans('notifications.action_view_page'), $page->getUrl())
+            ->line($this->buildReasonFooterLine($locale));
     }
 }
diff --git a/app/Activity/Notifications/Messages/PageUpdateNotification.php b/app/Activity/Notifications/Messages/PageUpdateNotification.php
index 8e2d27fa4..1c8155d29 100644
--- a/app/Activity/Notifications/Messages/PageUpdateNotification.php
+++ b/app/Activity/Notifications/Messages/PageUpdateNotification.php
@@ -14,17 +14,17 @@ class PageUpdateNotification extends BaseActivityNotification
         /** @var Page $page */
         $page = $this->detail;
 
-        $language = $notifiable->getLanguage();
+        $locale = $notifiable->getLocale();
 
-        return $this->newMailMessage($language)
-            ->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()], $language))
-            ->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')], $language))
+        return $this->newMailMessage($locale)
+            ->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
+            ->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
             ->line(new ListMessageLine([
-                trans('notifications.detail_page_name', [], $language) => $page->name,
-                trans('notifications.detail_updated_by', [], $language) => $this->user->name,
+                $locale->trans('notifications.detail_page_name') => $page->name,
+                $locale->trans('notifications.detail_updated_by') => $this->user->name,
             ]))
-            ->line(trans('notifications.updated_page_debounce', [], $language))
-            ->action(trans('notifications.action_view_page', [], $language), $page->getUrl())
-            ->line($this->buildReasonFooterLine($language));
+            ->line($locale->trans('notifications.updated_page_debounce'))
+            ->action($locale->trans('notifications.action_view_page'), $page->getUrl())
+            ->line($this->buildReasonFooterLine($locale));
     }
 }
diff --git a/app/App/MailNotification.php b/app/App/MailNotification.php
index 8c57b5621..50b7f69a7 100644
--- a/app/App/MailNotification.php
+++ b/app/App/MailNotification.php
@@ -2,6 +2,7 @@
 
 namespace BookStack\App;
 
+use BookStack\Translation\LocaleDefinition;
 use BookStack\Users\Models\User;
 use Illuminate\Bus\Queueable;
 use Illuminate\Contracts\Queue\ShouldQueue;
@@ -32,9 +33,9 @@ abstract class MailNotification extends Notification implements ShouldQueue
     /**
      * Create a new mail message.
      */
-    protected function newMailMessage(string $language = ''): MailMessage
+    protected function newMailMessage(?LocaleDefinition $locale = null): MailMessage
     {
-        $data = ['language' => $language ?: null];
+        $data = ['locale' => $locale ?? user()->getLocale()];
 
         return (new MailMessage())->view([
             'html' => 'vendor.notifications.email',
diff --git a/app/Config/app.php b/app/Config/app.php
index 3a843c512..dcd3ffc31 100644
--- a/app/Config/app.php
+++ b/app/Config/app.php
@@ -83,10 +83,10 @@ return [
     'timezone' => env('APP_TIMEZONE', 'UTC'),
 
     // Default locale to use
+    // A default variant is also stored since Laravel can overwrite
+    // app.locale when dynamically setting the locale in-app.
     'locale' => env('APP_LANG', 'en'),
-
-    // Locales available
-    'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl',  'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
+    'default_locale' => env('APP_LANG', 'en'),
 
     //  Application Fallback Locale
     'fallback_locale' => 'en',
@@ -94,9 +94,6 @@ return [
     // Faker Locale
     'faker_locale' => 'en_GB',
 
-    // Enable right-to-left text control.
-    'rtl' => false,
-
     // Auto-detect the locale for public users
     // For public users their locale can be guessed by headers sent by their
     // browser. This is usually set by users in their browser settings.
diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php
index 9e4d63cf7..6779797d1 100644
--- a/app/Entities/Tools/ExportFormatter.php
+++ b/app/Entities/Tools/ExportFormatter.php
@@ -16,18 +16,11 @@ use Throwable;
 
 class ExportFormatter
 {
-    protected ImageService $imageService;
-    protected PdfGenerator $pdfGenerator;
-    protected CspService $cspService;
-
-    /**
-     * ExportService constructor.
-     */
-    public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
-    {
-        $this->imageService = $imageService;
-        $this->pdfGenerator = $pdfGenerator;
-        $this->cspService = $cspService;
+    public function __construct(
+        protected ImageService $imageService,
+        protected PdfGenerator $pdfGenerator,
+        protected CspService $cspService
+    ) {
     }
 
     /**
@@ -36,13 +29,14 @@ class ExportFormatter
      *
      * @throws Throwable
      */
-    public function pageToContainedHtml(Page $page)
+    public function pageToContainedHtml(Page $page): string
     {
         $page->html = (new PageContent($page))->render();
         $pageHtml = view('exports.page', [
             'page'       => $page,
             'format'     => 'html',
             'cspContent' => $this->cspService->getCspMetaTagValue(),
+            'locale'     => user()->getLocale(),
         ])->render();
 
         return $this->containHtml($pageHtml);
@@ -53,7 +47,7 @@ class ExportFormatter
      *
      * @throws Throwable
      */
-    public function chapterToContainedHtml(Chapter $chapter)
+    public function chapterToContainedHtml(Chapter $chapter): string
     {
         $pages = $chapter->getVisiblePages();
         $pages->each(function ($page) {
@@ -64,6 +58,7 @@ class ExportFormatter
             'pages'      => $pages,
             'format'     => 'html',
             'cspContent' => $this->cspService->getCspMetaTagValue(),
+            'locale'     => user()->getLocale(),
         ])->render();
 
         return $this->containHtml($html);
@@ -74,7 +69,7 @@ class ExportFormatter
      *
      * @throws Throwable
      */
-    public function bookToContainedHtml(Book $book)
+    public function bookToContainedHtml(Book $book): string
     {
         $bookTree = (new BookContents($book))->getTree(false, true);
         $html = view('exports.book', [
@@ -82,6 +77,7 @@ class ExportFormatter
             'bookChildren' => $bookTree,
             'format'       => 'html',
             'cspContent'   => $this->cspService->getCspMetaTagValue(),
+            'locale'       => user()->getLocale(),
         ])->render();
 
         return $this->containHtml($html);
@@ -92,13 +88,14 @@ class ExportFormatter
      *
      * @throws Throwable
      */
-    public function pageToPdf(Page $page)
+    public function pageToPdf(Page $page): string
     {
         $page->html = (new PageContent($page))->render();
         $html = view('exports.page', [
             'page'   => $page,
             'format' => 'pdf',
             'engine' => $this->pdfGenerator->getActiveEngine(),
+            'locale' => user()->getLocale(),
         ])->render();
 
         return $this->htmlToPdf($html);
@@ -109,7 +106,7 @@ class ExportFormatter
      *
      * @throws Throwable
      */
-    public function chapterToPdf(Chapter $chapter)
+    public function chapterToPdf(Chapter $chapter): string
     {
         $pages = $chapter->getVisiblePages();
         $pages->each(function ($page) {
@@ -121,6 +118,7 @@ class ExportFormatter
             'pages'   => $pages,
             'format'  => 'pdf',
             'engine'  => $this->pdfGenerator->getActiveEngine(),
+            'locale'  => user()->getLocale(),
         ])->render();
 
         return $this->htmlToPdf($html);
@@ -131,7 +129,7 @@ class ExportFormatter
      *
      * @throws Throwable
      */
-    public function bookToPdf(Book $book)
+    public function bookToPdf(Book $book): string
     {
         $bookTree = (new BookContents($book))->getTree(false, true);
         $html = view('exports.book', [
@@ -139,6 +137,7 @@ class ExportFormatter
             'bookChildren' => $bookTree,
             'format'       => 'pdf',
             'engine'       => $this->pdfGenerator->getActiveEngine(),
+            'locale'       => user()->getLocale(),
         ])->render();
 
         return $this->htmlToPdf($html);
@@ -194,7 +193,7 @@ class ExportFormatter
         /** @var DOMElement $iframe */
         foreach ($iframes as $iframe) {
             $link = $iframe->getAttribute('src');
-            if (strpos($link, '//') === 0) {
+            if (str_starts_with($link, '//')) {
                 $link = 'https:' . $link;
             }
 
@@ -240,7 +239,7 @@ class ExportFormatter
             foreach ($linksOutput[0] as $index => $linkMatch) {
                 $oldLinkString = $linkMatch;
                 $srcString = $linksOutput[2][$index];
-                if (strpos(trim($srcString), 'http') !== 0) {
+                if (!str_starts_with(trim($srcString), 'http')) {
                     $newSrcString = url($srcString);
                     $newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
                     $htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent);
diff --git a/app/Http/Middleware/Localization.php b/app/Http/Middleware/Localization.php
index 47723e242..94d06a627 100644
--- a/app/Http/Middleware/Localization.php
+++ b/app/Http/Middleware/Localization.php
@@ -2,17 +2,14 @@
 
 namespace BookStack\Http\Middleware;
 
-use BookStack\Translation\LanguageManager;
-use Carbon\Carbon;
+use BookStack\Translation\LocaleManager;
 use Closure;
 
 class Localization
 {
-    protected LanguageManager $languageManager;
-
-    public function __construct(LanguageManager $languageManager)
-    {
-        $this->languageManager = $languageManager;
+    public function __construct(
+        protected LocaleManager $localeManager
+    ) {
     }
 
     /**
@@ -25,22 +22,12 @@ class Localization
      */
     public function handle($request, Closure $next)
     {
-        // Get and record the default language in the config
-        $defaultLang = config('app.locale');
-        config()->set('app.default_locale', $defaultLang);
+        // Share details of the user's locale for use in views
+        $userLocale = $this->localeManager->getForUser(user());
+        view()->share('locale', $userLocale);
 
-        // Get the user's language and record that in the config for use in views
-        $userLang = $this->languageManager->getUserLanguage($request, $defaultLang);
-        config()->set('app.lang', str_replace('_', '-', $this->languageManager->getIsoName($userLang)));
-
-        // Set text direction
-        if ($this->languageManager->isRTL($userLang)) {
-            config()->set('app.rtl', true);
-        }
-
-        app()->setLocale($userLang);
-        Carbon::setLocale($userLang);
-        $this->languageManager->setPhpDateTimeLocale($userLang);
+        // Set locale for system components
+        app()->setLocale($userLocale->appLocale());
 
         return $next($request);
     }
diff --git a/app/Translation/LanguageManager.php b/app/Translation/LanguageManager.php
deleted file mode 100644
index f3432d038..000000000
--- a/app/Translation/LanguageManager.php
+++ /dev/null
@@ -1,148 +0,0 @@
-<?php
-
-namespace BookStack\Translation;
-
-use BookStack\Users\Models\User;
-use Illuminate\Http\Request;
-
-class LanguageManager
-{
-    /**
-     * Array of right-to-left language options.
-     */
-    protected array $rtlLanguages = ['ar', 'fa', 'he'];
-
-    /**
-     * Map of BookStack language names to best-estimate ISO and windows locale names.
-     * Locales can often be found by running `locale -a` on a linux system.
-     * Windows locales can be found at:
-     * https://docs.microsoft.com/en-us/cpp/c-runtime-library/language-strings?view=msvc-170.
-     *
-     * @var array<string, array{iso: string, windows: string}>
-     */
-    protected array $localeMap = [
-        'ar'          => ['iso' => 'ar', 'windows' => 'Arabic'],
-        'bg'          => ['iso' => 'bg_BG', 'windows' => 'Bulgarian'],
-        'bs'          => ['iso' => 'bs_BA', 'windows' => 'Bosnian (Latin)'],
-        'ca'          => ['iso' => 'ca', 'windows' => 'Catalan'],
-        'cs'          => ['iso' => 'cs_CZ', 'windows' => 'Czech'],
-        'da'          => ['iso' => 'da_DK', 'windows' => 'Danish'],
-        'de'          => ['iso' => 'de_DE', 'windows' => 'German'],
-        'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
-        'en'          => ['iso' => 'en_GB', 'windows' => 'English'],
-        'el'          => ['iso' => 'el_GR', 'windows' => 'Greek'],
-        'es'          => ['iso' => 'es_ES', 'windows' => 'Spanish'],
-        'es_AR'       => ['iso' => 'es_AR', 'windows' => 'Spanish'],
-        'et'          => ['iso' => 'et_EE', 'windows' => 'Estonian'],
-        'eu'          => ['iso' => 'eu_ES', 'windows' => 'Basque'],
-        'fa'          => ['iso' => 'fa_IR', 'windows' => 'Persian'],
-        'fr'          => ['iso' => 'fr_FR', 'windows' => 'French'],
-        'he'          => ['iso' => 'he_IL', 'windows' => 'Hebrew'],
-        'hr'          => ['iso' => 'hr_HR', 'windows' => 'Croatian'],
-        'hu'          => ['iso' => 'hu_HU', 'windows' => 'Hungarian'],
-        'id'          => ['iso' => 'id_ID', 'windows' => 'Indonesian'],
-        'it'          => ['iso' => 'it_IT', 'windows' => 'Italian'],
-        'ja'          => ['iso' => 'ja', 'windows' => 'Japanese'],
-        'ko'          => ['iso' => 'ko_KR', 'windows' => 'Korean'],
-        'lt'          => ['iso' => 'lt_LT', 'windows' => 'Lithuanian'],
-        'lv'          => ['iso' => 'lv_LV', 'windows' => 'Latvian'],
-        'nl'          => ['iso' => 'nl_NL', 'windows' => 'Dutch'],
-        'nb'          => ['iso' => 'nb_NO', 'windows' => 'Norwegian (Bokmal)'],
-        'pl'          => ['iso' => 'pl_PL', 'windows' => 'Polish'],
-        'pt'          => ['iso' => 'pt_PT', 'windows' => 'Portuguese'],
-        'pt_BR'       => ['iso' => 'pt_BR', 'windows' => 'Portuguese'],
-        'ro'          => ['iso' => 'ro_RO', 'windows' => 'Romanian'],
-        'ru'          => ['iso' => 'ru', 'windows' => 'Russian'],
-        'sk'          => ['iso' => 'sk_SK', 'windows' => 'Slovak'],
-        'sl'          => ['iso' => 'sl_SI', 'windows' => 'Slovenian'],
-        'sv'          => ['iso' => 'sv_SE', 'windows' => 'Swedish'],
-        'uk'          => ['iso' => 'uk_UA', 'windows' => 'Ukrainian'],
-        'uz'          => ['iso' => 'uz_UZ', 'windows' => 'Uzbek'],
-        'vi'          => ['iso' => 'vi_VN', 'windows' => 'Vietnamese'],
-        'zh_CN'       => ['iso' => 'zh_CN', 'windows' => 'Chinese (Simplified)'],
-        'zh_TW'       => ['iso' => 'zh_TW', 'windows' => 'Chinese (Traditional)'],
-        'tr'          => ['iso' => 'tr_TR', 'windows' => 'Turkish'],
-    ];
-
-    /**
-     * Get the language specifically for the currently logged-in user if available.
-     */
-    public function getUserLanguage(Request $request, string $default): string
-    {
-        try {
-            $user = user();
-        } catch (\Exception $exception) {
-            return $default;
-        }
-
-        if ($user->isGuest() && config('app.auto_detect_locale')) {
-            return $this->autoDetectLocale($request, $default);
-        }
-
-        return setting()->getUser($user, 'language', $default);
-    }
-
-    /**
-     * Get the language for the given user.
-     */
-    public function getLanguageForUser(User $user): string
-    {
-        $default = config('app.locale');
-        return setting()->getUser($user, 'language', $default);
-    }
-
-    /**
-     * Check if the given BookStack language value is a right-to-left language.
-     */
-    public function isRTL(string $language): bool
-    {
-        return in_array($language, $this->rtlLanguages);
-    }
-
-    /**
-     * Autodetect the visitors locale by matching locales in their headers
-     * against the locales supported by BookStack.
-     */
-    protected function autoDetectLocale(Request $request, string $default): string
-    {
-        $availableLocales = config('app.locales');
-        foreach ($request->getLanguages() as $lang) {
-            if (in_array($lang, $availableLocales)) {
-                return $lang;
-            }
-        }
-
-        return $default;
-    }
-
-    /**
-     * Get the ISO version of a BookStack language name.
-     */
-    public function getIsoName(string $language): string
-    {
-        return $this->localeMap[$language]['iso'] ?? $language;
-    }
-
-    /**
-     * Set the system date locale for localized date formatting.
-     * Will try both the standard locale name and the UTF8 variant.
-     */
-    public function setPhpDateTimeLocale(string $language): void
-    {
-        $isoLang = $this->localeMap[$language]['iso'] ?? '';
-        $isoLangPrefix = explode('_', $isoLang)[0];
-
-        $locales = array_values(array_filter([
-            $isoLang ? $isoLang . '.utf8' : false,
-            $isoLang ?: false,
-            $isoLang ? str_replace('_', '-', $isoLang) : false,
-            $isoLang ? $isoLangPrefix . '.UTF-8' : false,
-            $this->localeMap[$language]['windows'] ?? false,
-            $language,
-        ]));
-
-        if (!empty($locales)) {
-            setlocale(LC_TIME, $locales[0], ...array_slice($locales, 1));
-        }
-    }
-}
diff --git a/app/Translation/LocaleDefinition.php b/app/Translation/LocaleDefinition.php
new file mode 100644
index 000000000..85d36afa0
--- /dev/null
+++ b/app/Translation/LocaleDefinition.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace BookStack\Translation;
+
+class LocaleDefinition
+{
+    public function __construct(
+        protected string $appName,
+        protected string $isoName,
+        protected bool $isRtl
+    ) {
+    }
+
+    /**
+     * Provide the BookStack-specific locale name.
+     */
+    public function appLocale(): string
+    {
+        return $this->appName;
+    }
+
+    /**
+     * Provide the ISO-aligned locale name.
+     */
+    public function isoLocale(): string
+    {
+        return $this->isoName;
+    }
+
+    /**
+     * Returns a string suitable for the HTML "lang" attribute.
+     */
+    public function htmlLang(): string
+    {
+        return str_replace('_', '-', $this->isoName);
+    }
+
+    /**
+     * Returns a string suitable for the HTML "dir" attribute.
+     */
+    public function htmlDirection(): string
+    {
+        return $this->isRtl ? 'rtl' : 'ltr';
+    }
+
+    /**
+     * Translate using this locate.
+     */
+    public function trans(string $key, array $replace = []): string
+    {
+        return trans($key, $replace, $this->appLocale());
+    }
+}
diff --git a/app/Translation/LocaleManager.php b/app/Translation/LocaleManager.php
new file mode 100644
index 000000000..825eae7a4
--- /dev/null
+++ b/app/Translation/LocaleManager.php
@@ -0,0 +1,119 @@
+<?php
+
+namespace BookStack\Translation;
+
+use BookStack\Users\Models\User;
+use Illuminate\Http\Request;
+
+class LocaleManager
+{
+    /**
+     * Array of right-to-left locale options.
+     */
+    protected array $rtlLocales = ['ar', 'fa', 'he'];
+
+    /**
+     * Map of BookStack locale names to best-estimate ISO locale names.
+     * Locales can often be found by running `locale -a` on a linux system.
+     *
+     * @var array<string, string>
+     */
+    protected array $localeMap = [
+        'ar'          => 'ar',
+        'bg'          => 'bg_BG',
+        'bs'          => 'bs_BA',
+        'ca'          => 'ca',
+        'cs'          => 'cs_CZ',
+        'cy'          => 'cy_GB',
+        'da'          => 'da_DK',
+        'de'          => 'de_DE',
+        'de_informal' => 'de_DE',
+        'el'          => 'el_GR',
+        'en'          => 'en_GB',
+        'es'          => 'es_ES',
+        'es_AR'       => 'es_AR',
+        'et'          => 'et_EE',
+        'eu'          => 'eu_ES',
+        'fa'          => 'fa_IR',
+        'fr'          => 'fr_FR',
+        'he'          => 'he_IL',
+        'hr'          => 'hr_HR',
+        'hu'          => 'hu_HU',
+        'id'          => 'id_ID',
+        'it'          => 'it_IT',
+        'ja'          => 'ja',
+        'ka'          => 'ka_GE',
+        'ko'          => 'ko_KR',
+        'lt'          => 'lt_LT',
+        'lv'          => 'lv_LV',
+        'nb'          => 'nb_NO',
+        'nl'          => 'nl_NL',
+        'pl'          => 'pl_PL',
+        'pt'          => 'pt_PT',
+        'pt_BR'       => 'pt_BR',
+        'ro'          => 'ro_RO',
+        'ru'          => 'ru',
+        'sk'          => 'sk_SK',
+        'sl'          => 'sl_SI',
+        'sv'          => 'sv_SE',
+        'tr'          => 'tr_TR',
+        'uk'          => 'uk_UA',
+        'uz'          => 'uz_UZ',
+        'vi'          => 'vi_VN',
+        'zh_CN'       => 'zh_CN',
+        'zh_TW'       => 'zh_TW',
+    ];
+
+    /**
+     * Get the BookStack locale string for the given user.
+     */
+    protected function getLocaleForUser(User $user): string
+    {
+        $default = config('app.default_locale');
+
+        if ($user->isGuest() && config('app.auto_detect_locale')) {
+            return $this->autoDetectLocale(request(), $default);
+        }
+
+        return setting()->getUser($user, 'language', $default);
+    }
+
+    /**
+     * Get a locale definition for the current user.
+     */
+    public function getForUser(User $user): LocaleDefinition
+    {
+        $localeString = $this->getLocaleForUser($user);
+
+        return new LocaleDefinition(
+            $localeString,
+            $this->localeMap[$localeString] ?? $localeString,
+            in_array($localeString, $this->rtlLocales),
+        );
+    }
+
+    /**
+     * Autodetect the visitors locale by matching locales in their headers
+     * against the locales supported by BookStack.
+     */
+    protected function autoDetectLocale(Request $request, string $default): string
+    {
+        $availableLocales = $this->getAllAppLocales();
+
+        foreach ($request->getLanguages() as $lang) {
+            if (in_array($lang, $availableLocales)) {
+                return $lang;
+            }
+        }
+
+        return $default;
+    }
+
+    /**
+     * Get all the available app-specific level locale strings.
+     */
+    public function getAllAppLocales(): array
+    {
+        return array_keys($this->localeMap);
+    }
+}
diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php
index 232ea8832..39236c7e4 100644
--- a/app/Users/Models/User.php
+++ b/app/Users/Models/User.php
@@ -12,7 +12,8 @@ use BookStack\Api\ApiToken;
 use BookStack\App\Model;
 use BookStack\App\Sluggable;
 use BookStack\Entities\Tools\SlugGenerator;
-use BookStack\Translation\LanguageManager;
+use BookStack\Translation\LocaleDefinition;
+use BookStack\Translation\LocaleManager;
 use BookStack\Uploads\Image;
 use Carbon\Carbon;
 use Exception;
@@ -342,11 +343,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     }
 
     /**
-     * Get the system language for this user.
+     * Get the locale for this user.
      */
-    public function getLanguage(): string
+    public function getLocale(): LocaleDefinition
     {
-        return app()->make(LanguageManager::class)->getLanguageForUser($this);
+        return app()->make(LocaleManager::class)->getForUser($this);
     }
 
     /**
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index 8875788a6..f303aff26 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -1,6 +1,6 @@
 <!DOCTYPE html>
-<html lang="{{ config('app.lang') }}"
-      dir="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+<html lang="{{ isset($locale) ? $locale->htmlLang() : config('app.default_locale') }}"
+      dir="{{ isset($locale) ? $locale->htmlDirection() : 'auto' }}"
       class="{{ setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '' }}">
 <head>
     <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}</title>
diff --git a/resources/views/layouts/export.blade.php b/resources/views/layouts/export.blade.php
index e041d8dea..eb2397a75 100644
--- a/resources/views/layouts/export.blade.php
+++ b/resources/views/layouts/export.blade.php
@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="{{ config('app.lang') }}">
+<html lang="{{ $locale->htmlLang() }}">
 <head>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
     <title>@yield('title')</title>
diff --git a/resources/views/layouts/plain.blade.php b/resources/views/layouts/plain.blade.php
index 043d8aa48..a3ee74143 100644
--- a/resources/views/layouts/plain.blade.php
+++ b/resources/views/layouts/plain.blade.php
@@ -1,6 +1,6 @@
 <!DOCTYPE html>
-<html lang="{{ config('app.lang') }}"
-      dir="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+<html lang="{{ isset($locale) ? $locale->htmlLang() : config('app.default_locale') }}"
+      dir="{{ isset($locale) ? $locale->htmlDirection() : 'auto' }}"
       class="@yield('document-class')">
 <head>
     <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}</title>
diff --git a/resources/views/pages/parts/markdown-editor.blade.php b/resources/views/pages/parts/markdown-editor.blade.php
index c488f0e11..18a418f10 100644
--- a/resources/views/pages/parts/markdown-editor.blade.php
+++ b/resources/views/pages/parts/markdown-editor.blade.php
@@ -1,6 +1,6 @@
 <div id="markdown-editor" component="markdown-editor"
      option:markdown-editor:page-id="{{ $model->id ?? 0 }}"
-     option:markdown-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+     option:markdown-editor:text-direction="{{ $locale->htmlDirection() }}"
      option:markdown-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
      option:markdown-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}"
      class="flex-fill flex code-fill">
diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php
index b7cd1bdaa..ca6b6da8a 100644
--- a/resources/views/pages/parts/wysiwyg-editor.blade.php
+++ b/resources/views/pages/parts/wysiwyg-editor.blade.php
@@ -3,9 +3,9 @@
 @endpush
 
 <div component="wysiwyg-editor"
-     option:wysiwyg-editor:language="{{ config('app.lang') }}"
+     option:wysiwyg-editor:language="{{ $locale->htmlLang() }}"
      option:wysiwyg-editor:page-id="{{ $model->id ?? 0 }}"
-     option:wysiwyg-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+     option:wysiwyg-editor:text-direction="{{ $locale->htmlDirection() }}"
      option:wysiwyg-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
      option:wysiwyg-editor:server-upload-limit-text="{{ trans('errors.server_upload_limit') }}"
      class="flex-fill flex">
diff --git a/resources/views/users/create.blade.php b/resources/views/users/create.blade.php
index 540d7bd6a..dafc623e1 100644
--- a/resources/views/users/create.blade.php
+++ b/resources/views/users/create.blade.php
@@ -14,7 +14,7 @@
 
                 <div class="setting-list">
                     @include('users.parts.form')
-                    @include('users.parts.language-option-row', ['value' => old('setting.language') ?? config('app.default_locale')])
+                    @include('users.parts.language-option-row', ['value' => old('language') ?? config('app.default_locale')])
                 </div>
 
                 <div class="form-group text-right">
diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php
index 4e31e785d..832186930 100644
--- a/resources/views/users/edit.blade.php
+++ b/resources/views/users/edit.blade.php
@@ -16,7 +16,8 @@
 
                     <div class="grid half gap-xl">
                         <div>
-                            <label for="user-avatar" class="setting-list-label">{{ trans('settings.users_avatar') }}</label>
+                            <label for="user-avatar"
+                                   class="setting-list-label">{{ trans('settings.users_avatar') }}</label>
                             <p class="small">{{ trans('settings.users_avatar_desc') }}</p>
                         </div>
                         <div>
@@ -33,13 +34,15 @@
                         </div>
                     </div>
 
-                    @include('users.parts.language-option-row', ['value' => setting()->getUser($user, 'language', config('app.default_locale'))])
+                    @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()])
                 </div>
 
                 <div class="text-right">
-                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}"
+                       class="button outline">{{ trans('common.cancel') }}</a>
                     @if($authMethod !== 'system')
-                        <a href="{{ url("/settings/users/{$user->id}/delete") }}" class="button outline">{{ trans('settings.users_delete') }}</a>
+                        <a href="{{ url("/settings/users/{$user->id}/delete") }}"
+                           class="button outline">{{ trans('settings.users_delete') }}</a>
                     @endif
                     <button class="button" type="submit">{{ trans('common.save') }}</button>
                 </div>
@@ -60,7 +63,8 @@
                 </div>
                 <div class="text-m-right">
                     @if($user->id === user()->id)
-                        <a href="{{ url('/mfa/setup')  }}" class="button outline">{{ trans('settings.users_mfa_configure') }}</a>
+                        <a href="{{ url('/mfa/setup')  }}"
+                           class="button outline">{{ trans('settings.users_mfa_configure') }}</a>
                     @endif
                 </div>
             </div>
@@ -84,7 +88,8 @@
                                                     class="button small outline">{{ trans('settings.users_social_disconnect') }}</button>
                                         </form>
                                     @else
-                                        <a href="{{ url("/login/service/{$driver}") }}" aria-label="{{ trans('settings.users_social_connect') }} - {{ $driver }}"
+                                        <a href="{{ url("/login/service/{$driver}") }}"
+                                           aria-label="{{ trans('settings.users_social_connect') }} - {{ $driver }}"
                                            class="button small outline">{{ trans('settings.users_social_connect') }}</a>
                                     @endif
                                 </div>
diff --git a/resources/views/vendor/notifications/email.blade.php b/resources/views/vendor/notifications/email.blade.php
index f5d9c328d..8e922fba5 100644
--- a/resources/views/vendor/notifications/email.blade.php
+++ b/resources/views/vendor/notifications/email.blade.php
@@ -1,5 +1,5 @@
 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html lang="{{ config('app.lang') }}">
+<html lang="{{ $locale->htmlLang() }}">
 <head>
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
@@ -159,7 +159,7 @@ $style = [
                                                             <tr>
                                                                 <td style="{{ $fontFamily }}">
                                                                     <p style="{{ $style['paragraph-sub'] }}">
-                                                                        {{ trans('common.email_action_help', ['actionText' => $actionText], $language) }}
+                                                                        {{ $locale->trans('common.email_action_help', ['actionText' => $actionText]) }}
                                                                     </p>
 
                                                                     <p style="{{ $style['paragraph-sub'] }}">
@@ -187,7 +187,7 @@ $style = [
                                                     <p style="{{ $style['paragraph-sub'] }}">
                                                         &copy; {{ date('Y') }}
                                                         <a style="{{ $style['anchor'] }}" href="{{ url('/') }}" target="_blank">{{ setting('app-name') }}</a>.
-                                                        {{ trans('common.email_rights', [], $language) }}
+                                                        {{ $locale->trans('common.email_rights') }}
                                                     </p>
                                                 </td>
                                             </tr>
diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php
index a66227ff2..6b6856184 100644
--- a/tests/LanguageTest.php
+++ b/tests/LanguageTest.php
@@ -3,6 +3,7 @@
 namespace Tests;
 
 use BookStack\Activity\ActivityType;
+use BookStack\Translation\LocaleManager;
 
 class LanguageTest extends TestCase
 {
@@ -17,12 +18,12 @@ class LanguageTest extends TestCase
         $this->langs = array_diff(scandir(lang_path('')), ['..', '.']);
     }
 
-    public function test_locales_config_key_set_properly()
+    public function test_locales_list_set_properly()
     {
-        $configLocales = config('app.locales');
-        sort($configLocales);
+        $appLocales = $this->app->make(LocaleManager::class)->getAllAppLocales();
+        sort($appLocales);
         sort($this->langs);
-        $this->assertEquals(implode(':', $configLocales), implode(':', $this->langs), 'app.locales configuration variable does not match those found in lang files');
+        $this->assertEquals(implode(':', $this->langs), implode(':', $appLocales), 'app.locales configuration variable does not match those found in lang files');
     }
 
     // Not part of standard phpunit test runs since we sometimes expect non-added langs.
@@ -75,13 +76,13 @@ class LanguageTest extends TestCase
         }
     }
 
-    public function test_rtl_config_set_if_lang_is_rtl()
+    public function test_views_use_rtl_if_rtl_language_is_set()
     {
-        $this->asEditor();
-        $this->assertFalse(config('app.rtl'), 'App RTL config should be false by default');
+        $this->asEditor()->withHtml($this->get('/'))->assertElementExists('html[dir="ltr"]');
+
         setting()->putUser($this->users->editor(), 'language', 'ar');
-        $this->get('/');
-        $this->assertTrue(config('app.rtl'), 'App RTL config should have been set to true by middleware');
+
+        $this->withHtml($this->get('/'))->assertElementExists('html[dir="rtl"]');
     }
 
     public function test_unknown_lang_does_not_break_app()
diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php
index a6d869b2f..93d35f5d0 100644
--- a/tests/User/UserManagementTest.php
+++ b/tests/User/UserManagementTest.php
@@ -215,7 +215,7 @@ class UserManagementTest extends TestCase
     {
         $langs = ['en', 'fr', 'hr'];
         foreach ($langs as $lang) {
-            config()->set('app.locale', $lang);
+            config()->set('app.default_locale', $lang);
             $resp = $this->asAdmin()->get('/settings/users/create');
             $this->withHtml($resp)->assertElementExists('select[name="language"] option[value="' . $lang . '"][selected]');
         }