diff --git a/djblets/siteconfig/managers.py b/djblets/siteconfig/managers.py
index 1dd3983841b02aefb7f07840e966e335eef5a880..0f7bbec610f8026c149c52ed70d6f3d8310cf30f 100644
--- a/djblets/siteconfig/managers.py
+++ b/djblets/siteconfig/managers.py
@@ -1,27 +1,4 @@
-#
-# managers.py -- Model managers for siteconfig objects
-#
-# Copyright (c) 2008-2009  Christian Hammond
-#
-# Permission is hereby granted, free of charge, to any person obtaining
-# a copy of this software and associated documentation files (the
-# "Software"), to deal in the Software without restriction, including
-# without limitation the rights to use, copy, modify, merge, publish,
-# distribute, sublicense, and/or sell copies of the Software, and to
-# permit persons to whom the Software is furnished to do so, subject to
-# the following conditions:
-#
-# The above copyright notice and this permission notice shall be included
-# in all copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-#
+"""Model and cache management for SiteConfiguration."""
 
 from __future__ import unicode_literals
 
@@ -29,49 +6,120 @@ from django.contrib.sites.models import Site
 from django.db import models
 from django.utils import six
 
+from djblets.siteconfig.signals import siteconfig_reloaded
+
 
 _SITECONFIG_CACHE = {}
 
 
 class SiteConfigurationManager(models.Manager):
+    """Manages cached instances of a SiteConfiguration.
+
+    This provides functions for retrieving the current
+    :py:class:`~djblets.siteconfig.models.SiteConfiguration` instance and
+    working with cache expiration. Consumers are expected to use
+    :py:meth:`get_current` to retrieve their instance, and are also expected
+    to use the :py:class:`~djblets.siteconfig.middleware.SettingsMiddleware`
+    to manage expiration between server processes.
     """
-    A Manager that provides a get_current function for retrieving the
-    SiteConfiguration for this particular running site.
-    """
+
     def get_current(self):
+        """Return the site configuration for the active site.
+
+        Multiple calls to this method for the same
+        :py:class:`~django.contrib.site.models.Site` will return the same
+        instance, as long as the old instance has not expired. Callers should
+        not store the result of this method, as it may not be valid for long.
+
+        Returns:
+            djblets.siteconfig.models.SiteConfiguration:
+            The current site configuration for the active site.
+
+        Raises:
+            django.core.exceptions.ImproperlyConfigured:
+                Site information wasn't configured in Django.
         """
-        Returns the site configuration on the active site.
-        """
-        from djblets.siteconfig.models import SiteConfiguration
-        global _SITECONFIG_CACHE
+        return self.get_for_site_id(Site.objects.get_current().pk)
 
-        # This will handle raising a ImproperlyConfigured if not set up
-        # properly.
-        site = Site.objects.get_current()
+    def get_for_site_id(self, site_id):
+        """Return the site configuration for a specific site ID.
 
-        if site.id not in _SITECONFIG_CACHE:
-            _SITECONFIG_CACHE[site.id] = \
-                SiteConfiguration.objects.get(site=site)
+        Multiple calls to this method for the same
+        :py:class:`~django.contrib.site.models.Site` will return the same
+        instance, as long as the old instance has not expired. Callers should
+        not store the result of this method, as it may not be valid for long.
+
+        Args:
+            site (int):
+                The ID of the site to retrieve the configuration for.
+
+        Returns:
+            djblets.siteconfig.models.SiteConfiguration:
+            The current site configuration for the specified site.
+        """
+        try:
+            siteconfig = _SITECONFIG_CACHE[site_id]
+        except KeyError:
+            siteconfig = self.model.objects.get(site_id=site_id)
+            _SITECONFIG_CACHE[site_id] = siteconfig
 
-        return _SITECONFIG_CACHE[site.id]
+        return siteconfig
 
     def clear_cache(self):
+        """Clear the entire SiteConfiguration cache.
+
+        The next call to :py:meth:`get_current` for any
+        :py:class:`~django.contrib.site.models.Site` will query the
+        database.
+        """
         global _SITECONFIG_CACHE
+
         _SITECONFIG_CACHE = {}
 
     def check_expired(self):
+        """Check whether any SiteConfigurations have expired.
+
+        If a :py:class:`~djblets.siteconfig.models.SiteConfiguration` has
+        expired (another process/server has saved a more recent version),
+        this method will expire the cache for the old version.
+
+        If there are any listeners for the
+        :py:data:`~djblets.siteconfig.signals.siteconfig_reloaded` signal,
+        a new :py:class:`~djblets.siteconfig.models.SiteConfiguration`
+        instance will be immediately loaded and the signal will fire.
+        Otherwise, a new instance will not be loaded right away.
+
+        This should be called on each HTTP request. It's recommended that
+        consumers use
+        :py:class:`~djblets.siteconfig.middleware.SettingsMiddleware` to do
+        this. It can also be called manually for long-living processes that
+        aren't bound to HTTP requests.
+
+        .. versionchanged:: 1.0.3
+
+           The :py:data:`~djblets.siteconfig.signals.siteconfig_reloaded`
+           signal is now emitted with a newly-fetched instance if there are
+           any listeners.
         """
-        Checks each cached SiteConfiguration to find out if its settings
-        have expired. This should be called on each request to ensure that
-        the copy of the settings is up-to-date in case another web server
-        worker process modifies the settings in the database.
-        """
-        global _SITECONFIG_CACHE
+        send_signal = siteconfig_reloaded.has_listeners()
 
-        for key, siteconfig in six.iteritems(_SITECONFIG_CACHE.copy()):
+        for site_id, siteconfig in six.iteritems(_SITECONFIG_CACHE.copy()):
             if siteconfig.is_expired():
                 try:
                     # This is stale. Get rid of it so we can load it next time.
-                    del _SITECONFIG_CACHE[key]
+                    del _SITECONFIG_CACHE[site_id]
                 except KeyError:
-                    pass
+                    # Another thread probably took care of this. We're done
+                    # with this one.
+                    continue
+
+                # If there are any listeners to the signal, then reload the
+                # SiteConfiguration now and let consumers know about it.
+                # If there aren't any listeners, we save a database query,
+                # and the instance will be loaded next time a caller
+                # requests it.
+                if send_signal:
+                    siteconfig_reloaded.send(
+                        sender=None,
+                        siteconfig=self.get_for_site_id(site_id),
+                        old_siteconfig=siteconfig)
diff --git a/djblets/siteconfig/signals.py b/djblets/siteconfig/signals.py
new file mode 100644
index 0000000000000000000000000000000000000000..23bdeeaaefa261949be82e4e3773cc21d36c9a9d
--- /dev/null
+++ b/djblets/siteconfig/signals.py
@@ -0,0 +1,21 @@
+from __future__ import unicode_literals
+
+from django.dispatch import Signal
+
+
+#: Emitted when a SiteConfiguration has loaded.
+#:
+#: This can be used by callers that depend on
+#: :py:class:`~djblets.siteconfig.models.SiteConfiguration` to handle reloading
+#: or recomputing data from settings that may have changed in another process
+#: or server.
+#:
+#: Args:
+#:     siteconfig (djblets.siteconfig.models.SiteConfiguration)
+#:         The site configuration that has been loaded.
+#:
+#:     old_siteconfig (djblets.siteconfig.models.SiteConfiguration)
+#:         The old site configuration. The caller can compare the settings
+#:         between the new one and this one to see if it needs to handle
+#:         anything.
+siteconfig_reloaded = Signal(providing_args=['siteconfig', 'old_siteconfig'])
diff --git a/djblets/siteconfig/tests.py b/djblets/siteconfig/tests.py
index 774acd580de625a7f70dae856d9d1c1916016ca9..0f5f13b7ab446940f305e4b5ca3f8934464d9ed6 100644
--- a/djblets/siteconfig/tests.py
+++ b/djblets/siteconfig/tests.py
@@ -33,6 +33,7 @@ from djblets.siteconfig.django_settings import (apply_django_settings,
                                                 cache_settings_map,
                                                 mail_settings_map)
 from djblets.siteconfig.models import SiteConfiguration
+from djblets.siteconfig.signals import siteconfig_reloaded
 from djblets.testing.testcases import TestCase
 
 
@@ -77,49 +78,6 @@ class SiteConfigTest(TestCase):
         settings.EMAIL_HOST_USER.translate(hmac.trans_5C)
         settings.EMAIL_HOST_PASSWORD.translate(hmac.trans_5C)
 
-    def testSynchronization(self):
-        """Testing synchronizing SiteConfigurations through cache"""
-        siteconfig1 = SiteConfiguration.objects.get_current()
-        self.assertFalse(siteconfig1.is_expired())
-
-        siteconfig2 = SiteConfiguration.objects.get(site=self.siteconfig.site)
-        siteconfig2.set('foobar', 123)
-
-        # Save, and prevent clearing of caches to simulate still having the
-        # stale cache around for another thread.
-        siteconfig2.save(clear_caches=False)
-
-        self.assertTrue(siteconfig1.is_expired())
-
-        SiteConfiguration.objects.check_expired()
-
-        # See if we fetch the same one again
-        siteconfig1 = SiteConfiguration.objects.get_current()
-        self.assertEqual(siteconfig1.get('foobar'), 123)
-
-    def testSynchronizationExpiredCache(self):
-        """Testing synchronizing SiteConfigurations with an expired cache"""
-        siteconfig1 = SiteConfiguration.objects.get_current()
-        self.assertFalse(siteconfig1.is_expired())
-
-        siteconfig2 = SiteConfiguration.objects.get(site=self.siteconfig.site)
-        siteconfig2.set('foobar', 123)
-
-        # Save, and prevent clearing of caches to simulate still having the
-        # stale cache around for another thread.
-        siteconfig2.save(clear_caches=False)
-
-        cache.delete('%s:siteconfig:%s:generation' %
-                     (siteconfig2.site.domain, siteconfig2.id))
-
-        self.assertTrue(siteconfig1.is_expired())
-
-        SiteConfiguration.objects.check_expired()
-
-        # See if we fetch the same one again
-        siteconfig1 = SiteConfiguration.objects.get_current()
-        self.assertEqual(siteconfig1.get('foobar'), 123)
-
     def test_cache_backend(self):
         """Testing cache backend setting with CACHES['default']"""
         settings.CACHES = {
@@ -299,3 +257,101 @@ class SiteConfigTest(TestCase):
                 'valid_key_2': 'valid_parameter_2',
                 'valid_key_3': 'valid_parameter_3',
             })
+
+
+class SiteConfigurationManagerTests(TestCase):
+    """Unit tests for SiteConfigurationManager."""
+
+    def setUp(self):
+        super(SiteConfigurationManagerTests, self).setUp()
+
+        self.siteconfig = SiteConfiguration(site=Site.objects.get_current())
+        self.siteconfig.save()
+
+    def tearDown(self):
+        super(SiteConfigurationManagerTests, self).tearDown()
+
+        self.siteconfig.delete()
+        SiteConfiguration.objects.clear_cache()
+
+    def test_check_expired_with_stale_cache(self):
+        """Testing SiteConfigurationManager.check_expired with stale cache"""
+        siteconfig1 = SiteConfiguration.objects.get_current()
+        self.assertFalse(siteconfig1.is_expired())
+
+        siteconfig2 = SiteConfiguration.objects.get(site=self.siteconfig.site)
+        siteconfig2.set('foobar', 123)
+
+        # Save, and prevent clearing of caches to simulate still having the
+        # stale cache around for another thread.
+        siteconfig2.save(clear_caches=False)
+
+        self.assertTrue(siteconfig1.is_expired())
+
+        SiteConfiguration.objects.check_expired()
+
+        # See if we fetch the same one again
+        siteconfig1 = SiteConfiguration.objects.get_current()
+        self.assertEqual(siteconfig1.get('foobar'), 123)
+
+    def test_check_expired_with_expired_cache(self):
+        """Testing SiteConfigurationManager.check_expired with an expired
+        state in cache
+        """
+        siteconfig1 = SiteConfiguration.objects.get_current()
+        self.assertFalse(siteconfig1.is_expired())
+
+        siteconfig2 = SiteConfiguration.objects.get(site=self.siteconfig.site)
+        siteconfig2.set('foobar', 123)
+
+        # Save, and prevent clearing of caches to simulate still having the
+        # stale cache around for another thread.
+        siteconfig2.save(clear_caches=False)
+
+        cache.delete('%s:siteconfig:%s:generation' %
+                     (siteconfig2.site.domain, siteconfig2.id))
+
+        self.assertTrue(siteconfig1.is_expired())
+
+        SiteConfiguration.objects.check_expired()
+
+        # See if we fetch the same one again
+        siteconfig1 = SiteConfiguration.objects.get_current()
+        self.assertEqual(siteconfig1.get('foobar'), 123)
+
+    def test_check_expired_emits_reloaded_signal(self):
+        """Testing SiteConfigurationManager.check_expired emits
+        siteconfig_reloaded when expired
+        """
+        signal_seen = []
+
+        def _on_siteconfig_reloaded(siteconfig, old_siteconfig, **kwargs):
+            self.assertIsNot(siteconfig, siteconfig1)
+            self.assertIsNot(siteconfig, siteconfig2)
+            self.assertIs(old_siteconfig, siteconfig1)
+            self.assertEqual(old_siteconfig.settings, siteconfig1.settings)
+            self.assertEqual(siteconfig.settings, siteconfig2.settings)
+            signal_seen.append(1)
+
+        siteconfig_reloaded.connect(_on_siteconfig_reloaded)
+
+        siteconfig1 = SiteConfiguration.objects.get_current()
+        self.assertFalse(siteconfig1.is_expired())
+
+        siteconfig2 = SiteConfiguration.objects.get(site=self.siteconfig.site)
+        siteconfig2.set('foobar', 123)
+
+        # Save, and prevent clearing of caches to simulate still having the
+        # stale cache around for another thread.
+        siteconfig2.save(clear_caches=False)
+
+        self.assertTrue(siteconfig1.is_expired())
+
+        SiteConfiguration.objects.check_expired()
+
+        # See if we fetch the same one again.
+        siteconfig1 = SiteConfiguration.objects.get_current()
+        self.assertEqual(siteconfig1.get('foobar'), 123)
+
+        # See if the signal was emitted.
+        self.assertTrue(signal_seen)
