diff --git a/CHANGES.rst b/CHANGES.rst
index c8cd133..b4ff40c 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -29,6 +29,8 @@
   property
 * Changed: ``coaster.sqlalchemy.UrlForMixin`` now recognises that the project
   may have multiple apps with distinct URLs for the same content
+* New: ``coaster.sqlalchemy.registry`` provides two registries for forms and
+  views associated with models
 
 
 0.6.0
diff --git a/coaster/sqlalchemy/__init__.py b/coaster/sqlalchemy/__init__.py
index 039dbfb..31da757 100644
--- a/coaster/sqlalchemy/__init__.py
+++ b/coaster/sqlalchemy/__init__.py
@@ -18,6 +18,7 @@ from .functions import *  # NOQA
 from .roles import *  # NOQA
 from .annotations import *  # NOQA
 from .immutable_annotation import *  # NOQA
+from .registry import *  # NOQA
 from .mixins import *  # NOQA
 from .columns import *  # NOQA
 from .statemanager import *  # NOQA
diff --git a/coaster/sqlalchemy/mixins.py b/coaster/sqlalchemy/mixins.py
index df9a7a2..682edc7 100644
--- a/coaster/sqlalchemy/mixins.py
+++ b/coaster/sqlalchemy/mixins.py
@@ -37,6 +37,7 @@ from ..utils.misc import _punctuation_re
 from ..auth import current_auth
 from .immutable_annotation import immutable
 from .roles import RoleMixin, with_roles
+from .registry import RegistryMixin
 from .comparators import Query, SqlSplitIdComparator, SqlHexUuidComparator, SqlBuidComparator, SqlSuuidComparator
 from .functions import auto_init_default, failsafe_add
 
@@ -44,7 +45,7 @@ from .functions import auto_init_default, failsafe_add
 __all__ = ['IdMixin', 'TimestampMixin', 'PermissionMixin', 'UrlForMixin',
     'NoIdMixin', 'BaseMixin', 'BaseNameMixin', 'BaseScopedNameMixin', 'BaseIdNameMixin',
     'BaseScopedIdMixin', 'BaseScopedIdNameMixin', 'CoordinatesMixin',
-    'UuidMixin', 'RoleMixin']
+    'UuidMixin', 'RoleMixin', 'RegistryMixin']
 
 
 class IdMixin(object):
@@ -268,11 +269,11 @@ class UrlForMixin(object):
         return decorator
 
 
-class NoIdMixin(TimestampMixin, PermissionMixin, RoleMixin, UrlForMixin):
+class NoIdMixin(TimestampMixin, PermissionMixin, RoleMixin, RegistryMixin, UrlForMixin):
     """
-    Mixin that combines :class:`TimestampMixin`, :class:`PermissionMixin`,
-    :class:`RoleMixin` and :class:`UrlForMixin`, for use anywhere where the
-    timestamp columns and helper methods are required, but an id column is not.
+    Mixin that combines all mixin classes except :class:`IdMixin`, for use
+    anywhere the timestamp columns and helper methods are required, but an
+    id column is not.
     """
     def _set_fields(self, fields):
         """Helper method for :meth:`upsert` in the various subclasses"""
diff --git a/coaster/sqlalchemy/registry.py b/coaster/sqlalchemy/registry.py
new file mode 100644
index 0000000..e5a0f86
--- /dev/null
+++ b/coaster/sqlalchemy/registry.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+
+"""
+Model helper registry
+---------------------
+
+Provides a :class:`Registry` type and a :class:`RegistryMixin` base class
+with two registries, used by other mixin classes.
+
+Helper classes such as forms and views can be registered to the model and
+later accessed from an instance::
+
+    class MyModel(BaseMixin, db.Model):
+        ...
+
+    class MyForm(Form):
+        ...
+
+    class MyView(ModelView):
+        ...
+
+    MyModel.forms.main = MyForm
+    MyModel.views.main = MyView
+
+When accessed from an instance, the registered form or view will receive the
+instance as an ``obj`` parameter::
+
+    doc = MyModel()
+    doc.forms.main() == MyForm(obj=doc)
+    doc.views.main() == MyView(obj=doc)
+
+The name ``main`` is a recommended default, but an app that has separate forms
+for ``new`` and ``edit`` actions could use those names instead.
+"""
+
+from functools import partial
+from sqlalchemy.ext.declarative import declared_attr
+
+
+__all__ = ['Registry', 'InstanceRegistry', 'RegistryMixin']
+
+
+class Registry(object):
+    """
+    Container for items registered to a model.
+    """
+    def __get__(self, obj, cls=None):
+        if obj is None:
+            return self
+        else:
+            return InstanceRegistry(self, obj)
+
+
+class InstanceRegistry(object):
+    """
+    Container for accessing registered items from an instance of the model.
+    Used internally by :class:`Registry`. Returns a partial that will pass
+    in an ``obj`` parameter when called.
+    """
+    def __init__(self, registry, obj):
+        self.__registry = registry
+        self.__obj = obj
+
+    def __getattr__(self, attr):
+        return partial(getattr(self.__registry, attr), obj=self.__obj)
+
+
+class RegistryMixin(object):
+    """
+    Provides the :attr:`forms` and :attr:`views` registries using
+    :class:`Registry`. Additional registries, if needed, should be
+    added directly to the model class.
+    """
+    @declared_attr
+    def forms(self):
+        return Registry()
+
+    @declared_attr
+    def views(self):
+        return Registry()
diff --git a/coaster/views/classview.py b/coaster/views/classview.py
index 8fd5d89..48e6988 100644
--- a/coaster/views/classview.py
+++ b/coaster/views/classview.py
@@ -450,6 +450,7 @@ class ModelView(ClassView):
             def view(self):
                 return self.obj.current_access()
 
+        Document.views.main = DocumentView
         DocumentView.init_app(app)
 
     Views will not receive view arguments, unlike in :class:`ClassView`. If
@@ -474,6 +475,10 @@ class ModelView(ClassView):
     #: SQLAlchemy attribute references to load the instance object.
     route_model_map = {}
 
+    def __init__(self, obj=None):
+        super(ModelView, self).__init__()
+        self.obj = obj
+
     def dispatch_request(self, view, view_args):
         """
         View dispatcher that calls :meth:`before_request`, :meth:`loader`,
diff --git a/docs/sqlalchemy/index.rst b/docs/sqlalchemy/index.rst
index 87b32a7..c0f1054 100644
--- a/docs/sqlalchemy/index.rst
+++ b/docs/sqlalchemy/index.rst
@@ -9,5 +9,6 @@
    roles
    annotations
    immutable_annotation
+   registry
    comparators
    statemanager
diff --git a/docs/sqlalchemy/registry.rst b/docs/sqlalchemy/registry.rst
new file mode 100644
index 0000000..4537704
--- /dev/null
+++ b/docs/sqlalchemy/registry.rst
@@ -0,0 +1,2 @@
+.. automodule:: coaster.sqlalchemy.registry
+   :members:

ModelView discovery from models
`UrlForMixin` combined with `UrlForView` allows a model to be queried for the URL to a view. However, we often need more metadata:

1. Whether the view is available to the current user. At this time the logic for this is embedded into the template, making it difficult to maintain.

2. A title, icon and category when displaying link buttons (once again hardcoded into the template at present).

3. Enumeration of available views, or available _related_ views.

Similar to `model.url_for(action)`, we could have a `model.view_for(action)` that returns the `ViewHandler` from a `ModelView` affiliated with the model. View handlers have a `data` attribute that could be used by the template: `{{ model.view_for('edit').data['title'] }}`. 

If #179 (`requires_roles`) is implemented using the `ViewDecorator` class, it can also offer an `is_available` property similar to that offered by `StateTransition`:

https://github.com/hasgeek/coaster/blob/672096556e58e1bb3653a9f429e4d568f265ca04/coaster/sqlalchemy/statemanager.py#L554-L559

A limited form of view discovery is available from the `current_view` proxy, which returns the current view class and allows examining its contents. There is a justifiable need for discovering `ModelView` classes that are _not_ currently active.
diff --git a/tests/test_sqlalchemy_registry.py b/tests/test_sqlalchemy_registry.py
new file mode 100644
index 0000000..cc74b9a
--- /dev/null
+++ b/tests/test_sqlalchemy_registry.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+
+import unittest
+
+from coaster.sqlalchemy import BaseMixin
+from coaster.db import db
+
+# We have two sample models and two registered items to test that
+# the registry is unique to each model and is not a global registry
+# in the base class.
+
+# Sample model 1
+class RegistryTest1(BaseMixin, db.Model):
+    __tablename__ = 'registry_test1'
+
+
+# Sample model 2
+class RegistryTest2(BaseMixin, db.Model):
+    __tablename__ = 'registry_test2'
+
+
+# Sample registered item (form or view) 1
+class RegisteredItem1(object):
+    def __init__(self, obj=None):
+        self.obj = obj
+
+
+# Sample registered item 2
+class RegisteredItem2(object):
+    def __init__(self, obj=None):
+        self.obj = obj
+
+RegistryTest1.views.test = RegisteredItem1
+RegistryTest2.views.test = RegisteredItem2
+
+
+class TestRegistry(unittest.TestCase):
+    def test_access_item_from_class(self):
+        """Registered items are available from the model class"""
+        assert RegistryTest1.views.test is RegisteredItem1
+        assert RegistryTest2.views.test is RegisteredItem2
+        assert RegistryTest1.views.test is not RegisteredItem2
+        assert RegistryTest2.views.test is not RegisteredItem1
+
+    def test_access_item_class_from_instance(self):
+        """Registered items are available from the model instance"""
+        r1 = RegistryTest1()
+        r2 = RegistryTest2()
+        # When accessed from the instance, we get a partial that resembles
+        # the wrapped item, but is not the item itself.
+        assert r1.views.test is not RegisteredItem1
+        assert r1.views.test.func is RegisteredItem1
+        assert r2.views.test is not RegisteredItem2
+        assert r2.views.test.func is RegisteredItem2
+
+    def test_access_item_instance_from_instance(self):
+        """Registered items can be instantiated from the model instance"""
+        r1 = RegistryTest1()
+        r2 = RegistryTest2()
+        i1 = r1.views.test()
+        i2 = r2.views.test()
+
+        assert isinstance(i1, RegisteredItem1)
+        assert isinstance(i2, RegisteredItem2)
+        assert not isinstance(i1, RegisteredItem2)
+        assert not isinstance(i2, RegisteredItem1)
+        assert i1.obj is r1
+        assert i2.obj is r2
+        assert i1.obj is not r2
+        assert i2.obj is not r1
diff --git a/tests/test_views_classview.py b/tests/test_views_classview.py
index 764fb20..026ba93 100644
--- a/tests/test_views_classview.py
+++ b/tests/test_views_classview.py
@@ -209,6 +209,7 @@ class ModelDocumentView(UrlForView, InstanceLoader, ModelView):
         return 'edit-called'
 
 
+ViewDocument.views.main = ModelDocumentView
 ModelDocumentView.init_app(app)
 
 
@@ -221,6 +222,7 @@ class ScopedDocumentView(ModelDocumentView):
         }
 
 
+ScopedViewDocument.views.main = ScopedDocumentView
 ScopedDocumentView.init_app(app)
 
 
@@ -237,6 +239,7 @@ class RenameableDocumentView(UrlChangeCheck, InstanceLoader, ModelView):
         return self.obj.current_access()
 
 
+RenameableDocument.views.main = RenameableDocumentView
 RenameableDocumentView.init_app(app)
 
 
@@ -530,3 +533,18 @@ class TestClassView(unittest.TestCase):
         rv = self.client.get('/multi/test1/1-test2')
         assert rv.status_code == 200
         assert rv.data == b'/multi/test1/1-test2'
+
+    def test_registered_views(self):
+        doc1 = ViewDocument(name='test1', title="Test 1")
+        doc2 = ScopedViewDocument(name='test2', title="Test 2", parent=doc1)
+        doc3 = RenameableDocument(name='test3', title="Test 3")
+        self.session.add_all([doc1, doc2, doc3])
+        self.session.commit()
+
+        assert ViewDocument.views.main is ModelDocumentView
+        assert ScopedViewDocument.views.main is ScopedDocumentView
+        assert RenameableDocument.views.main is RenameableDocumentView
+
+        assert isinstance(doc1.views.main(), ModelDocumentView)
+        assert isinstance(doc2.views.main(), ScopedDocumentView)
+        assert isinstance(doc3.views.main(), RenameableDocumentView)

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest coverage coveralls psycopg2
apt-get update apt-get install -y gcc
3.6
test_requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alembic==1.7.7
attrs==22.2.0
bcrypt==4.0.1
bleach==4.1.0
blinker==1.5
certifi==2021.5.30
charset-normalizer==2.0.12
click==8.0.4
-e git+https://github.com/hasgeek/coaster.git@b9bfcc7923ac3bb7ef82bd3e214b9e8d4c5c2b59#egg=coaster
coverage==6.2
coveralls==3.3.1
dataclasses==0.8
docflow==0.3.3
docopt==0.6.2
filelock==3.4.1
Flask==2.0.3
Flask-Assets==2.1.0
Flask-Migrate==4.0.1
Flask-Script==2.0.6
Flask-SQLAlchemy==2.5.1
greenlet==2.0.2
html2text==2020.1.16
html5lib==1.1
idna==3.10
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig==1.1.1
isoweek==1.3.3
itsdangerous==2.0.1
Jinja2==3.0.3
joblib==1.1.1
Mako==1.1.6
Markdown==3.3.7
MarkupSafe==2.0.1
nltk==3.6.7
packaging==21.3
pluggy==1.0.0
psycopg2==2.7.7
py==1.11.0
PyExecJS==1.5.1
Pygments==2.14.0
pyparsing==3.1.4
pytest==7.0.1
pytz==2025.2
regex==2023.8.8
requests==2.27.1
requests-file==2.1.0
semantic-version==2.10.0
shortuuid==1.0.13
simplejson==3.20.1
six==1.17.0
SQLAlchemy==1.4.54
SQLAlchemy-Utils==0.41.1
tldextract==3.1.2
tomli==1.2.3
tqdm==4.64.1
typing_extensions==4.1.1
UgliPyJS==0.2.5
Unidecode==1.3.8
urllib3==1.26.20
webassets==2.0
webencodings==0.5.1
Werkzeug==2.0.3
zipp==3.6.0

name: coaster
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alembic==1.7.7
      - attrs==22.2.0
      - bcrypt==4.0.1
      - bleach==4.1.0
      - blinker==1.5
      - charset-normalizer==2.0.12
      - click==8.0.4
      - coverage==6.2
      - coveralls==3.3.1
      - dataclasses==0.8
      - docflow==0.3.3
      - docopt==0.6.2
      - filelock==3.4.1
      - flask==2.0.3
      - flask-assets==2.1.0
      - flask-migrate==4.0.1
      - flask-script==2.0.6
      - flask-sqlalchemy==2.5.1
      - greenlet==2.0.2
      - html2text==2020.1.16
      - html5lib==1.1
      - idna==3.10
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - iniconfig==1.1.1
      - isoweek==1.3.3
      - itsdangerous==2.0.1
      - jinja2==3.0.3
      - joblib==1.1.1
      - mako==1.1.6
      - markdown==3.3.7
      - markupsafe==2.0.1
      - nltk==3.6.7
      - packaging==21.3
      - pluggy==1.0.0
      - psycopg2==2.7.7
      - py==1.11.0
      - pyexecjs==1.5.1
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytz==2025.2
      - regex==2023.8.8
      - requests==2.27.1
      - requests-file==2.1.0
      - semantic-version==2.10.0
      - shortuuid==1.0.13
      - simplejson==3.20.1
      - six==1.17.0
      - sqlalchemy==1.4.54
      - sqlalchemy-utils==0.41.1
      - tldextract==3.1.2
      - tomli==1.2.3
      - tqdm==4.64.1
      - typing-extensions==4.1.1
      - uglipyjs==0.2.5
      - unidecode==1.3.8
      - urllib3==1.26.20
      - webassets==2.0
      - webencodings==0.5.1
      - werkzeug==2.0.3
      - zipp==3.6.0
prefix: /opt/conda/envs/coaster

tests/test_sqlalchemy_registry.py::TestRegistry::test_access_item_class_from_instance tests/test_sqlalchemy_registry.py::TestRegistry::test_access_item_from_class tests/test_sqlalchemy_registry.py::TestRegistry::test_access_item_instance_from_instance tests/test_views_classview.py::TestClassView::test_added_routes tests/test_views_classview.py::TestClassView::test_callable_view tests/test_views_classview.py::TestClassView::test_cant_route_missing_method tests/test_views_classview.py::TestClassView::test_current_handler_is_self tests/test_views_classview.py::TestClassView::test_current_handler_is_wrapper tests/test_views_classview.py::TestClassView::test_current_view tests/test_views_classview.py::TestClassView::test_document_404 tests/test_views_classview.py::TestClassView::test_document_edit tests/test_views_classview.py::TestClassView::test_document_view tests/test_views_classview.py::TestClassView::test_endpoints tests/test_views_classview.py::TestClassView::test_index tests/test_views_classview.py::TestClassView::test_inherited tests/test_views_classview.py::TestClassView::test_modelview_instanceloader_requires_permission_edit tests/test_views_classview.py::TestClassView::test_modelview_instanceloader_view tests/test_views_classview.py::TestClassView::test_modelview_url_for tests/test_views_classview.py::TestClassView::test_multi_view tests/test_views_classview.py::TestClassView::test_page tests/test_views_classview.py::TestClassView::test_redirectablemodel_view tests/test_views_classview.py::TestClassView::test_registered_views tests/test_views_classview.py::TestClassView::test_rerouted tests/test_views_classview.py::TestClassView::test_rerouted_with_new_routes tests/test_views_classview.py::TestClassView::test_scopedmodelview_view tests/test_views_classview.py::TestClassView::test_second_subview_reroute tests/test_views_classview.py::TestClassView::test_unrouted tests/test_views_classview.py::TestClassView::test_view_args_are_received tests/test_views_classview.py::TestClassView::test_viewdata tests/test_views_classview.py::TestClassView::test_viewlist
docs/sqlalchemy/index.rst coaster/sqlalchemy/registry.py coaster/sqlalchemy/mixins.py CHANGES.rst coaster/views/classview.py docs/sqlalchemy/registry.rst coaster/sqlalchemy/__init__.py
docs/sqlalchemy/index.rst coaster/sqlalchemy/registry.py coaster/sqlalchemy/mixins.py CHANGES.rst coaster/views/classview.py docs/sqlalchemy/registry.rst coaster/sqlalchemy/__init__.py