diff --git a/module/VuFind/src/VuFind/Bootstrapper.php b/module/VuFind/src/VuFind/Bootstrapper.php
index baf2aa69a544731d440bf2bc4a4017c138b4b8c4..261c1ef3596e0ac17455fbb9e9d74fc283a7158a 100644
--- a/module/VuFind/src/VuFind/Bootstrapper.php
+++ b/module/VuFind/src/VuFind/Bootstrapper.php
@@ -289,6 +289,30 @@ class Bootstrapper
         return false;
     }
 
+    /**
+     * Support method for initLanguage() -- look up all text domains.
+     *
+     * @return array
+     */
+    protected function getTextDomains()
+    {
+        $base = APPLICATION_PATH;
+        $local = LOCAL_OVERRIDE_DIR;
+        $languagePathParts = ["$base/languages"];
+        if (!empty($local)) {
+            $languagePathParts[] = "$local/languages";
+        }
+        $languagePathParts[] = "$base/themes/*/languages";
+
+        $domains = [];
+        foreach ($languagePathParts as $current) {
+            $places = glob($current . '/*', GLOB_ONLYDIR | GLOB_NOSORT);
+            $domains = array_merge($domains, array_map('basename', $places));
+        }
+
+        return array_unique($domains);
+    }
+
     /**
      * Set up language handling.
      *
@@ -325,11 +349,18 @@ class Bootstrapper
             if (!in_array($language, array_keys($config->Languages->toArray()))) {
                 $language = $config->Site->language;
             }
-
             try {
-                $sm->get('VuFind\Translator')
-                    ->addTranslationFile('ExtendedIni', null, 'default', $language)
-                    ->setLocale($language);
+                $translator = $sm->get('VuFind\Translator');
+                $translator->setLocale($language)
+                    ->addTranslationFile('ExtendedIni', null, 'default', $language);
+                foreach ($this->getTextDomains() as $domain) {
+                    // Set up text domains using the domain name as the filename;
+                    // this will help the ExtendedIni loader dynamically locate
+                    // the appropriate files.
+                    $translator->addTranslationFile(
+                        'ExtendedIni', $domain, $domain, $language
+                    );
+                }
             } catch (\Zend\Mvc\Exception\BadMethodCallException $e) {
                 if (!extension_loaded('intl')) {
                     throw new \Exception(
diff --git a/module/VuFind/src/VuFind/I18n/Translator/Loader/ExtendedIni.php b/module/VuFind/src/VuFind/I18n/Translator/Loader/ExtendedIni.php
index 83a6454b89c11c8f1cc7a19b9be4845b7ae7d79c..629b5100f7fedd07a34098d928cc70f977ec090d 100644
--- a/module/VuFind/src/VuFind/I18n/Translator/Loader/ExtendedIni.php
+++ b/module/VuFind/src/VuFind/I18n/Translator/Loader/ExtendedIni.php
@@ -107,12 +107,11 @@ class ExtendedIni implements FileLoaderInterface
      * Load method defined by FileLoaderInterface.
      *
      * @param string $locale   Locale to read from language file
-     * @param string $filename Language file to read (not used)
+     * @param string $filename Relative base path for language file (used for
+     * loading text domains; optional)
      *
      * @return TextDomain
      * @throws InvalidArgumentException
-     *
-     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
      */
     public function load($locale, $filename)
     {
@@ -120,12 +119,12 @@ class ExtendedIni implements FileLoaderInterface
         $this->resetLoadedFiles();
 
         // Load base data:
-        $data = $this->loadLanguageFile($locale . '.ini');
+        $data = $this->loadLanguageLocale($locale, $filename);
 
         // Load fallback data, if any:
         if (!empty($this->fallbackLocales)) {
             foreach ($this->fallbackLocales as $fallbackLocale) {
-                $newData = $this->loadLanguageFile($fallbackLocale . '.ini');
+                $newData = $this->loadLanguageLocale($fallbackLocale, $filename);
                 $newData->merge($data);
                 $data = $newData;
             }
@@ -134,6 +133,21 @@ class ExtendedIni implements FileLoaderInterface
         return $data;
     }
 
+    /**
+     * Get the language file name for a language and domain
+     *
+     * @param string $locale Locale name
+     * @param string $domain Text domain (if any)
+     *
+     * @return string
+     */
+    public function getLanguageFilename($locale, $domain)
+    {
+        return empty($domain)
+            ? $locale . '.ini'
+            : $domain . '/' . $locale . '.ini';
+    }
+
     /**
      * Reset the loaded file list.
      *
@@ -160,14 +174,33 @@ class ExtendedIni implements FileLoaderInterface
         return false;
     }
 
+    /**
+     * Load the language file for a given locale and domain.
+     *
+     * @param string $locale Locale name
+     * @param string $domain Text domain (if any)
+     *
+     * @return TextDomain
+     */
+    protected function loadLanguageLocale($locale, $domain)
+    {
+        $filename = $this->getLanguageFilename($locale, $domain);
+        // Load the language file, and throw a fatal exception if it's missing
+        // and we're not dealing with text domains. A missing base file is an
+        // unexpected, fatal error; a missing domain-specific file is more likely
+        // due to the possibility of incomplete translations.
+        return $this->loadLanguageFile($filename, empty($domain));
+    }
+
     /**
      * Search the path stack for language files and merge them together.
      *
-     * @param string $filename Name of file to search path stack for.
+     * @param string $filename    Name of file to search path stack for.
+     * @param bool   $failOnError If true, throw an exception when file not found.
      *
      * @return TextDomain
      */
-    protected function loadLanguageFile($filename)
+    protected function loadLanguageFile($filename, $failOnError = true)
     {
         // Don't load a file that has already been loaded:
         if ($this->checkAndMarkLoadedFile($filename)) {
@@ -189,7 +222,13 @@ class ExtendedIni implements FileLoaderInterface
             }
         }
         if ($data === false) {
-            throw new InvalidArgumentException("Ini file '{$filename}' not found");
+            // Should we throw an exception? If not, return an empty result:
+            if ($failOnError) {
+                throw new InvalidArgumentException(
+                    "Ini file '{$filename}' not found"
+                );
+            }
+            return new TextDomain();
         }
 
         return $data;
diff --git a/module/VuFind/src/VuFind/I18n/Translator/TranslatorAwareTrait.php b/module/VuFind/src/VuFind/I18n/Translator/TranslatorAwareTrait.php
index ee06acb90b4871068400f9ca95844a74b44b90fb..723560f53d2fcab6985613673eb9d4eb4338398a 100644
--- a/module/VuFind/src/VuFind/I18n/Translator/TranslatorAwareTrait.php
+++ b/module/VuFind/src/VuFind/I18n/Translator/TranslatorAwareTrait.php
@@ -125,8 +125,11 @@ trait TranslatorAwareTrait
      */
     protected function translateString($str, $tokens = [], $default = null)
     {
-        $msg = null === $this->translator
-            ? $str : $this->translator->translate($str);
+        // Figure out the text domain for the string:
+        list($domain, $str) = $this->extractTextDomain($str);
+
+        $msg = (null === $this->translator)
+            ? $str : $this->translator->translate($str, $domain);
 
         // Did the translation fail to change anything?  If so, use default:
         if (null !== $default && $msg == $str) {
@@ -145,4 +148,21 @@ trait TranslatorAwareTrait
 
         return $msg;
     }
+
+    /**
+     * Given a translation string with or without a text domain, return an
+     * array with the raw string and the text domain separated.
+     *
+     * @param string $str String to parse
+     *
+     * @return array
+     */
+    protected function extractTextDomain($str)
+    {
+        $parts = explode('::', $str);
+        if (count($parts) == 2) {
+            return $parts;
+        }
+        return ['default', $str];
+    }
 }