diff --git a/aleksis/apps/matrix/migrations/0002_drop_onetoone.py b/aleksis/apps/matrix/migrations/0002_drop_onetoone.py
new file mode 100644
index 0000000000000000000000000000000000000000..85a6201dcbebb651c6287b879e1b50512dab27b1
--- /dev/null
+++ b/aleksis/apps/matrix/migrations/0002_drop_onetoone.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.10 on 2022-01-06 15:06
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0031_auto_20211228_0008'),
+        ('matrix', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='matrixroom',
+            name='group',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='matrix_room', to='core.group', verbose_name='Group'),
+        ),
+    ]
diff --git a/aleksis/apps/matrix/model_extensions.py b/aleksis/apps/matrix/model_extensions.py
index 2fca9b202dfddae2d01dd7fd589ec3537ea1442c..ab620422e697417717caa69f7d7519761abdfdbb 100644
--- a/aleksis/apps/matrix/model_extensions.py
+++ b/aleksis/apps/matrix/model_extensions.py
@@ -29,17 +29,15 @@ def _use_in_matrix(self):
 @Group.property_
 def matrix_alias(self) -> Optional[str]:
     """Return the alias of the group's room in Matrix."""
-    if hasattr(self, "matrix_room"):
-        return self.matrix_room.alias
-    return None
+    rooms = [room for room in self.matrix_rooms.all() if type(room) == MatrixRoom]
+    return rooms[0].alias if rooms else None
 
 
 @Group.property_
 def matrix_room_id(self) -> Optional[str]:
     """Return the ID of the group's room in Matrix."""
-    if hasattr(self, "matrix_room"):
-        return self.matrix_room.room_id
-    return None
+    rooms = [room for room in self.matrix_rooms.all() if type(room) == MatrixRoom]
+    return rooms[0].room_id if rooms else None
 
 
 Group.add_permission("view_matrixroom", _("Can view matrix room of a group"))
diff --git a/aleksis/apps/matrix/models.py b/aleksis/apps/matrix/models.py
index fff467f7a0ab722c3cd9536cad8ef1da5945e827..6b5858fefe39f4f61fa8a75c61e91bf40a4be108 100644
--- a/aleksis/apps/matrix/models.py
+++ b/aleksis/apps/matrix/models.py
@@ -55,37 +55,45 @@ class MatrixRoom(ExtensiblePolymorphicModel):
 
     room_id = models.CharField(max_length=255, verbose_name=_("Room ID"), unique=True)
     alias = models.CharField(max_length=255, verbose_name=_("Alias"), unique=True, blank=True)
-    group = models.OneToOneField(
+    group = models.ForeignKey(
         Group,
         on_delete=models.CASCADE,
         verbose_name=_("Group"),
-        related_name="matrix_room",
+        related_name="matrix_rooms",
     )
 
     @classmethod
-    def from_group(self, group: Group):
+    def get_queryset(cls):
+        return cls.objects.not_instance_of(MatrixSpace)
+
+    @classmethod
+    def build_alias(cls, group: Group) -> str:
+        return slugify(group.short_name or group.name)
+
+    @classmethod
+    def from_group(cls, group: Group) -> "MatrixRoom":
         """Create a Matrix room from a group."""
         from .matrix import MatrixException, do_matrix_request
 
         try:
-            room = MatrixRoom.objects.get(group=group)
-        except MatrixRoom.DoesNotExist:
-            room = MatrixRoom(group=group)
+            room = cls.get_queryset().get(group=group)
+        except cls.DoesNotExist:
+            room = cls(group=group)
 
         if room.room_id:
             # Existing room, check if still accessible
             r = do_matrix_request("GET", f"directory/list/room/{room.room_id}")
         else:
             # Room does not exist, create it
-            alias = slugify(group.short_name or group.name)
+            alias = cls.build_alias(group)
             profiles_to_invite = list(
-                self.get_profiles_for_group(group).values_list("matrix_id", flat=True)
+                cls.get_profiles_for_group(group).values_list("matrix_id", flat=True)
             )
 
             alias_found = False
             while not alias_found:
                 try:
-                    r = self._create_room(group.name, alias, profiles_to_invite)
+                    r = cls._create_room(group.name, alias, profiles_to_invite)
                     alias_found = True
                 except MatrixException as e:
                     print(e.args, get_site_preferences()["matrix__disambiguate_room_aliases"])
@@ -111,10 +119,23 @@ class MatrixRoom(ExtensiblePolymorphicModel):
         return room
 
     @classmethod
-    def _create_room(self, name, alias, invite: List[str]):
+    def _create_room(
+        self,
+        name,
+        alias,
+        invite: Optional[List[str]] = None,
+        creation_content: Optional[dict] = None,
+    ) -> Dict[str, Any]:
         from .matrix import do_matrix_request
 
-        body = {"preset": "private_chat", "name": name, "room_alias_name": alias, "invite": invite}
+        body = {"preset": "private_chat", "name": name, "room_alias_name": alias}
+
+        if invite:
+            body["invite"] = invite
+
+        if creation_content:
+            body["creation_content"] = creation_content
+
         r = do_matrix_request("POST", "createRoom", body=body)
 
         return r
@@ -210,9 +231,18 @@ class MatrixRoom(ExtensiblePolymorphicModel):
                 user_levels[profile.matrix_id] = 0
         self._set_power_levels(user_levels)
 
+    def sync_space(self):
+        if self.group.child_groups.all():
+            # Do space stuff
+            space = MatrixSpace.from_group(self.group)
+            space.sync()
+        return None
+
     def sync(self):
         """Sync this room."""
         self.sync_profiles()
+        if get_site_preferences()["matrix__use_spaces"]:
+            self.sync_space()
 
     class Meta:
         verbose_name = _("Matrix room")
@@ -225,6 +255,77 @@ class MatrixSpace(MatrixRoom):
         to=MatrixRoom, verbose_name=_("Child rooms/spaces"), blank=True, related_name="parents"
     )
 
+    @classmethod
+    def get_queryset(cls):
+        return cls.objects.instance_of(MatrixSpace)
+
+    @classmethod
+    def build_alias(cls, group: Group) -> str:
+        """Build an alias for this space."""
+        return slugify(group.short_name or group.name) + "-space"
+
+    @classmethod
+    def _create_room(
+        self,
+        name,
+        alias,
+        invite: Optional[List[str]] = None,
+        creation_content: Optional[dict] = None,
+    ) -> Dict[str, Any]:
+        if not creation_content:
+            creation_content = {}
+        creation_content["type"] = "m.space"
+        return super()._create_room(name, alias, invite, creation_content)
+
+    @property
+    def child_spaces(self) -> List[str]:
+        """Get all child spaces of this space."""
+        from aleksis.apps.matrix.matrix import do_matrix_request
+
+        r = do_matrix_request("GET", f"rooms/{self.room_id}/state")
+        return [c["state_key"] for c in r if c["type"] == "m.space.child"]
+
+    def _add_child(self, room_id: str):
+        """Add a child room to this space."""
+        r = do_matrix_request(
+            "PUT",
+            f"/_matrix/client/v3/rooms/{self.room_id}/state/m.space.child/{room_id}",
+            body={"via": [get_site_preferences()["matrix__homeserver_ids"]]},
+        )
+        return r
+
+    def sync_children(self):
+        """Sync membership of child spaces and rooms."""
+        current_children = self.child_spaces
+        child_spaces = MatrixSpace.get_queryset().filter(
+            group__in=self.group.child_groups.filter(child_groups__isnull=False)
+        )
+        child_rooms = MatrixRoom.get_queryset().filter(
+            Q(group__in=self.group.child_groups.filter(child_groups__isnull=True))
+            | Q(group=self.group)
+        )
+
+        child_ids = [m.room_id for m in list(child_spaces) + list(child_rooms)]
+
+        missing_ids = set(child_ids).difference(set(current_children))
+
+        for missing_id in missing_ids:
+            self._add_child(missing_id)
+
+    def ensure_children(self):
+        """Ensure that all child rooms/spaces exist."""
+        for group in self.group.child_groups.all().prefetch_related("child_groups"):
+            if group.child_groups.all():
+                space = MatrixSpace.from_group(group)
+                space.ensure_children()
+            else:
+                group.use_in_matrix(sync=True)
+
+    def sync(self):
+        """Sync this space."""
+        self.ensure_children()
+        self.sync_children()
+
     class Meta:
         verbose_name = _("Matrix space")
         verbose_name_plural = _("Matrix spaces")
diff --git a/aleksis/apps/matrix/preferences.py b/aleksis/apps/matrix/preferences.py
index 72967e2e5552e04962ede652277b79e8d72d8c89..f1d43e52b2115c2f85952410dfa87e2ec9407520 100644
--- a/aleksis/apps/matrix/preferences.py
+++ b/aleksis/apps/matrix/preferences.py
@@ -63,3 +63,11 @@ class DisambiguateRoomAliases(BooleanPreference):
     name = "disambiguate_room_aliases"
     verbose_name = _("Disambiguate room aliases")
     default = True
+
+
+@site_preferences_registry.register
+class UseSpaces(BooleanPreference):
+    section = matrix
+    name = "use_spaces"
+    verbose_name = _("Use Matrix spaces")
+    default = True
diff --git a/aleksis/apps/matrix/tests/test_matrix.py b/aleksis/apps/matrix/tests/test_matrix.py
index f2a049e57fd957952a58ba00e84996bc35dd1e4c..6015b79ad8b8f40c13cdf912ba0c6ca7082856e3 100644
--- a/aleksis/apps/matrix/tests/test_matrix.py
+++ b/aleksis/apps/matrix/tests/test_matrix.py
@@ -8,7 +8,7 @@ import requests
 from celery.result import AsyncResult
 
 from aleksis.apps.matrix.matrix import do_matrix_request
-from aleksis.apps.matrix.models import MatrixProfile, MatrixRoom
+from aleksis.apps.matrix.models import MatrixProfile, MatrixRoom, MatrixSpace
 from aleksis.core.models import Group, Person, SchoolTerm
 from aleksis.core.util.core_helpers import get_site_preferences
 
@@ -282,3 +282,47 @@ class MatrixCeleryTest(TransactionTestCase):
         assert MatrixProfile.objects.all().count() == 1
         assert p1.matrix_profile
         assert p1.matrix_profile.matrix_id == "@test1:matrix.aleksis.example.org"
+
+
+def test_space_creation(matrix_bot_user):
+    parent_group = Group.objects.create(name="Test Group")
+    child_1 = Group.objects.create(name="Test Group 1")
+    child_2 = Group.objects.create(name="Test Group 2")
+    child_3 = Group.objects.create(name="Test Group 3")
+    parent_group.child_groups.set([child_1, child_2, child_3])
+
+    parent_group.use_in_matrix(sync=True)
+
+    get_site_preferences()["matrix__use_spaces"] = True
+
+    space = MatrixSpace.from_group(parent_group)
+
+    r = do_matrix_request("GET", f"rooms/{space.room_id}/state")
+
+    events = {x["type"]: x for x in r}
+
+    assert events["m.room.create"]["content"]["type"] == "m.space"
+
+    space.ensure_children()
+
+    rooms = MatrixRoom.get_queryset().values_list("group_id", flat=True)
+    assert child_1.pk in rooms
+    assert child_2.pk in rooms
+    assert child_3.pk in rooms
+
+    space.sync_children()
+
+    r = do_matrix_request("GET", f"rooms/{space.room_id}/state")
+    interesting_events = [x["state_key"] for x in r if x["type"] == "m.space.child"]
+
+    assert len(interesting_events) == 4
+
+    rooms = list(
+        MatrixRoom.get_queryset()
+        .filter(group__in=[parent_group, child_1, child_2, child_3])
+        .values_list("room_id", flat=True)
+    )
+
+    assert len(rooms) == 4
+
+    assert set(interesting_events) == set(rooms)