diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index 88a01c15cf28489fde10c788895a35758bf857db..d196b044c610ae9ad94627e2f65d34c8d57da1a7 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -21,7 +21,7 @@ from django.views.generic.edit import ModelFormMixin
 import reversion
 from easyaudit.models import CRUDEvent
 from guardian.admin import GuardedModelAdmin
-from jsonstore.fields import JSONField, JSONFieldMixin
+from jsonstore.fields import IntegerField, JSONField, JSONFieldMixin
 from material.base import Layout, LayoutNode
 from rules.contrib.admin import ObjectPermissionsModelAdmin
 
@@ -103,7 +103,7 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
     objects_all_sites = models.Manager()
 
     extra_permissions = []
-    
+
     def get_absolute_url(self) -> str:
         """Get the URL o a view representing this model instance."""
         pass
@@ -209,6 +209,66 @@ class ExtensibleModel(models.Model, metaclass=_ExtensibleModelBase):
 
         cls._safe_add(field, name)
 
+    @classmethod
+    def foreign_key(
+        cls,
+        field_name: str,
+        to: models.Model,
+        to_field: str = "pk",
+        to_field_type: JSONFieldMixin = IntegerField,
+        related_name: Optional[str] = None,
+    ) -> None:
+        """Add a virtual ForeignKey.
+
+        This works by storing the primary key (or any field passed in the to_field argument)
+        and adding a property that queries the desired model.
+
+        If the foreign model also is an ExtensibleModel, a reverse mapping is also added under
+        the related_name passed as argument, or this model's default related name.
+        """
+
+        id_field_name = f"{field_name}_id"
+        if related_name is None:
+            related_name = cls.Meta.default_related_name
+
+        # Add field to hold key to foreign model
+        id_field = to_field_type()
+        cls.field(**{id_field_name: id_field})
+
+        @property
+        def _virtual_fk(self) -> Optional[models.Model]:
+            id_field_val = getattr(self, id_field_name)
+            if id_field_val:
+                try:
+                    return to.objects.get(**{to_field: id_field_val})
+                except to.DoesNotExist:
+                    # We found a stale foreign key
+                    setattr(self, id_field_name, None)
+                    self.save()
+                    return None
+            else:
+                return None
+
+        @_virtual_fk.setter
+        def _virtual_fk(self, value: Optional[models.Model] = None) -> None:
+            if value is None:
+                id_field_val = None
+            else:
+                id_field_val = getattr(value, to_field)
+            setattr(self, id_field_name, id_field_val)
+
+        # Add property to wrap get/set on foreign model instance
+        cls._safe_add(_virtual_fk, field_name)
+
+        # Add related property on foreign model instance if it provides such an interface
+        if hasattr(to, "_safe_add"):
+
+            def _virtual_related(self) -> models.QuerySet:
+                id_field_val = getattr(self, to_field)
+                return cls.objects.filter(**{id_field_name: id_field_val})
+
+            to.property_(_virtual_related, related_name)
+
     @classmethod
     def syncable_fields(cls) -> List[models.Field]:
         """Collect all fields that can be synced on a model."""