Repository: django/django
Issue category: Feature Request

Issue:
Allow overriding BoundField class on forms and fields
Description
	 
It would be useful if there was an easy way to add CSS classes to the HTML element which is generated when rendering a BoundField. I propose adding field_css_class, similar to required_css_class and error_css_class. ​https://docs.djangoproject.com/en/5.0/ref/forms/api/#styling-required-or-erroneous-form-rows
Something like this has already been rejected in the past here: https://code.djangoproject.com/ticket/29189 . As I understand it the reason for the rejection was the upcoming templates-based form rendering and not really the feature itself. I may have missed the point though.
However, I still think there'd be some value in this even though we have templates-based renderers now. The div (in the case of the div renderer) is generated here: ​https://github.com/django/django/blob/main/django/forms/templates/django/forms/div.html , so it's not just about overriding django/forms/field.html if you want to avoid adding another nesting level. Also, css_classes exists and is documented.
Code-wise I propose extending the css_classes method as follows:
​https://github.com/django/django/blob/e2428292abaca4758a7508175d31667fe2dff57c/django/forms/boundfield.py#L220-L231
		 extra_classes = set(extra_classes or [])
+		if hasattr(self.form, "field_css_class"):
+			extra_classes.add(self.form.field_css_class)
		 if self.errors and hasattr(self.form, "error_css_class"):
I'm willing to do the work if this is accepted.
Allow overriding BoundField class on forms and fields
Description
	 
It would be useful if there was an easy way to add CSS classes to the HTML element which is generated when rendering a BoundField. I propose adding field_css_class, similar to required_css_class and error_css_class. ​https://docs.djangoproject.com/en/5.0/ref/forms/api/#styling-required-or-erroneous-form-rows
Something like this has already been rejected in the past here: https://code.djangoproject.com/ticket/29189 . As I understand it the reason for the rejection was the upcoming templates-based form rendering and not really the feature itself. I may have missed the point though.
However, I still think there'd be some value in this even though we have templates-based renderers now. The div (in the case of the div renderer) is generated here: ​https://github.com/django/django/blob/main/django/forms/templates/django/forms/div.html , so it's not just about overriding django/forms/field.html if you want to avoid adding another nesting level. Also, css_classes exists and is documented.
Code-wise I propose extending the css_classes method as follows:
​https://github.com/django/django/blob/e2428292abaca4758a7508175d31667fe2dff57c/django/forms/boundfield.py#L220-L231
		 extra_classes = set(extra_classes or [])
+		if hasattr(self.form, "field_css_class"):
+			extra_classes.add(self.form.field_css_class)
		 if self.errors and hasattr(self.form, "error_css_class"):
I'm willing to do the work if this is accepted.


Hints:

Patch:
diff --git a/django/forms/fields.py b/django/forms/fields.py
index 202a6d72c878..4bd9c352f270 100644
--- a/django/forms/fields.py
+++ b/django/forms/fields.py
@@ -95,6 +95,7 @@ class Field:
         "required": _("This field is required."),
     }
     empty_values = list(validators.EMPTY_VALUES)
+    bound_field_class = None
 
     def __init__(
         self,
@@ -111,6 +112,7 @@ def __init__(
         disabled=False,
         label_suffix=None,
         template_name=None,
+        bound_field_class=None,
     ):
         # required -- Boolean that specifies whether the field is required.
         #             True by default.
@@ -135,11 +137,13 @@ def __init__(
         #             is its widget is shown in the form but not editable.
         # label_suffix -- Suffix to be added to the label. Overrides
         #                 form's label_suffix.
+        # bound_field_class -- BoundField class to use in Field.get_bound_field.
         self.required, self.label, self.initial = required, label, initial
         self.show_hidden_initial = show_hidden_initial
         self.help_text = help_text
         self.disabled = disabled
         self.label_suffix = label_suffix
+        self.bound_field_class = bound_field_class or self.bound_field_class
         widget = widget or self.widget
         if isinstance(widget, type):
             widget = widget()
@@ -251,7 +255,10 @@ def get_bound_field(self, form, field_name):
         Return a BoundField instance that will be used when accessing the form
         field in a template.
         """
-        return BoundField(form, self, field_name)
+        bound_field_class = (
+            self.bound_field_class or form.bound_field_class or BoundField
+        )
+        return bound_field_class(form, self, field_name)
 
     def __deepcopy__(self, memo):
         result = copy.copy(self)
diff --git a/django/forms/forms.py b/django/forms/forms.py
index 614f99039585..844f15f9f27f 100644
--- a/django/forms/forms.py
+++ b/django/forms/forms.py
@@ -68,6 +68,8 @@ class BaseForm(RenderableFormMixin):
     template_name_ul = "django/forms/ul.html"
     template_name_label = "django/forms/label.html"
 
+    bound_field_class = None
+
     def __init__(
         self,
         data=None,
@@ -81,6 +83,7 @@ def __init__(
         field_order=None,
         use_required_attribute=None,
         renderer=None,
+        bound_field_class=None,
     ):
         self.is_bound = data is not None or files is not None
         self.data = MultiValueDict() if data is None else data
@@ -124,6 +127,12 @@ def __init__(
                     renderer = renderer()
         self.renderer = renderer
 
+        self.bound_field_class = (
+            bound_field_class
+            or self.bound_field_class
+            or getattr(self.renderer, "bound_field_class", None)
+        )
+
     def order_fields(self, field_order):
         """
         Rearrange the fields according to field_order.
diff --git a/django/forms/renderers.py b/django/forms/renderers.py
index baf8f7450765..20eaf265df05 100644
--- a/django/forms/renderers.py
+++ b/django/forms/renderers.py
@@ -21,6 +21,8 @@ class BaseRenderer:
     formset_template_name = "django/forms/formsets/div.html"
     field_template_name = "django/forms/field.html"
 
+    bound_field_class = None
+
     def get_template(self, template_name):
         raise NotImplementedError("subclasses must implement get_template()")
 
diff --git a/docs/ref/forms/api.txt b/docs/ref/forms/api.txt
index 1b1ecbec6cf8..9b827ca69acc 100644
--- a/docs/ref/forms/api.txt
+++ b/docs/ref/forms/api.txt
@@ -822,6 +822,9 @@ classes, as needed. The HTML will look something like:
     >>> f["subject"].legend_tag(attrs={"class": "foo"})
     <legend for="id_subject" class="foo required">Subject:</legend>
 
+You may further modify the rendering of form rows by using a
+:ref:`custom BoundField <custom-boundfield>`.
+
 .. _ref-forms-api-configuring-label:
 
 Configuring form elements' HTML ``id`` attributes and ``<label>`` tags
@@ -1149,6 +1152,12 @@ they're not the only way a form object can be displayed.
 
    The ``__str__()`` method of this object displays the HTML for this field.
 
+   You can use :attr:`.Form.bound_field_class` and
+   :attr:`.Field.bound_field_class` to specify a different ``BoundField`` class
+   per form or per field, respectively.
+
+   See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.
+
 To retrieve a single ``BoundField``, use dictionary lookup syntax on your form
 using the field's name as the key:
 
@@ -1488,23 +1497,34 @@ Methods of ``BoundField``
         >>> print(bound_form["subject"].value())
         hi
 
+.. _custom-boundfield:
+
 Customizing ``BoundField``
 ==========================
 
-If you need to access some additional information about a form field in a
-template and using a subclass of :class:`~django.forms.Field` isn't
-sufficient, consider also customizing :class:`~django.forms.BoundField`.
+.. attribute:: Form.bound_field_class
 
-A custom form field can override ``get_bound_field()``:
+.. versionadded:: 5.2
 
-.. method:: Field.get_bound_field(form, field_name)
+Define a custom :class:`~django.forms.BoundField` class to use when rendering
+the form. This takes precedence over the project-level
+:attr:`.BaseRenderer.bound_field_class` (along with a custom
+:setting:`FORM_RENDERER`), but can be overridden by the field-level
+:attr:`.Field.bound_field_class`.
 
-    Takes an instance of :class:`~django.forms.Form` and the name of the field.
-    The return value will be used when accessing the field in a template. Most
-    likely it will be an instance of a subclass of
-    :class:`~django.forms.BoundField`.
+If not defined as a class variable, ``bound_field_class`` can be set via the
+``bound_field_class`` argument in the :class:`Form` or :class:`Field`
+constructor.
 
-If you have a ``GPSCoordinatesField``, for example, and want to be able to
+For compatibility reasons, a custom form field can still override
+:meth:`.Field.get_bound_field()` to use a custom class, though any of the
+previous options are preferred.
+
+You may want to use a custom :class:`.BoundField` if you need to access some
+additional information about a form field in a template and using a subclass of
+:class:`~django.forms.Field` isn't sufficient.
+
+For example, if you have a ``GPSCoordinatesField``, and want to be able to
 access additional information about the coordinates in a template, this could
 be implemented as follows::
 
@@ -1523,12 +1543,74 @@ be implemented as follows::
 
 
     class GPSCoordinatesField(Field):
-        def get_bound_field(self, form, field_name):
-            return GPSCoordinatesBoundField(form, self, field_name)
+        bound_field_class = GPSCoordinatesBoundField
 
 Now you can access the country in a template with
 ``{{ form.coordinates.country }}``.
 
+You may also want to customize the default form field template rendering. For
+example, you can override :meth:`.BoundField.label_tag` to add a custom class::
+
+   class StyledLabelBoundField(BoundField):
+       def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+           attrs = attrs or {}
+           attrs["class"] = "wide"
+           return super().label_tag(contents, attrs, label_suffix, tag)
+
+
+   class UserForm(forms.Form):
+       bound_field_class = StyledLabelBoundField
+       name = CharField()
+
+This would update the default form rendering:
+
+.. code-block:: pycon
+
+   >>> f = UserForm()
+   >>> print(f["name"].label_tag)
+   <label for="id_name" class="wide">Name:</label>
+
+To add a CSS class to the wrapping HTML element of all fields, a ``BoundField``
+can be overridden to return a different collection of CSS classes::
+
+    class WrappedBoundField(BoundField):
+        def css_classes(self, extra_classes=None):
+            parent_css_classes = super().css_classes(extra_classes)
+            return f"field-class {parent_css_classes}".strip()
+
+
+    class UserForm(forms.Form):
+        bound_field_class = WrappedBoundField
+        name = CharField()
+
+This would update the form rendering as follows:
+
+.. code-block:: pycon
+
+   >>> f = UserForm()
+   >>> print(f)
+   <div class="field-class"><label for="id_name">Name:</label><input type="text" name="name" required id="id_name"></div>
+
+Alternatively, to override the ``BoundField`` class at the project level,
+:attr:`.BaseRenderer.bound_field_class` can be defined on a custom
+:setting:`FORM_RENDERER`:
+
+.. code-block:: python
+    :caption: ``mysite/renderers.py``
+
+    from django.forms.renderers import DjangoTemplates
+
+    from .forms import CustomBoundField
+
+
+    class CustomRenderer(DjangoTemplates):
+        bound_field_class = CustomBoundField
+
+.. code-block:: python
+    :caption: ``settings.py``
+
+    FORM_RENDERER = "mysite.renderers.CustomRenderer"
+
 .. _binding-uploaded-files:
 
 Binding uploaded files to a form
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index c0ab4f2434c5..7ae5c121eb99 100644
--- a/docs/ref/forms/fields.txt
+++ b/docs/ref/forms/fields.txt
@@ -397,6 +397,16 @@ default this value is set to ``"django/forms/field.html"``. Can be changed per
 field by overriding this attribute or more generally by overriding the default
 template, see also :ref:`overriding-built-in-field-templates`.
 
+``bound_field_class``
+---------------------
+
+.. attribute:: Field.bound_field_class
+
+.. versionadded:: 5.2
+
+The ``bound_field_class`` attribute allows a per-field override of
+:attr:`.Form.bound_field_class`.
+
 Checking if the field data has changed
 ======================================
 
@@ -1635,4 +1645,14 @@ only requirements are that it implement a ``clean()`` method and that its
 ``label``, ``initial``, ``widget``, ``help_text``).
 
 You can also customize how a field will be accessed by overriding
-:meth:`~django.forms.Field.get_bound_field()`.
+:attr:`~django.forms.Field.bound_field_class` or override
+:meth:`.Field.get_bound_field()` if you need more flexibility when creating
+the ``BoundField``:
+
+.. method:: Field.get_bound_field(form, field_name)
+
+    Takes an instance of :class:`~django.forms.Form` and the name of the field.
+    The returned :class:`.BoundField` instance will be used when accessing the
+    field in a template.
+
+See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.
diff --git a/docs/ref/forms/renderers.txt b/docs/ref/forms/renderers.txt
index e527a70c5771..f52534651abd 100644
--- a/docs/ref/forms/renderers.txt
+++ b/docs/ref/forms/renderers.txt
@@ -65,6 +65,18 @@ should return a rendered templates (as a string) or raise
 
         Defaults to ``"django/forms/field.html"``
 
+    .. attribute:: bound_field_class
+
+        .. versionadded:: 5.2
+
+        The default class used to represent form fields across the project.
+
+        Defaults to :class:`.BoundField` class.
+
+        This can be customized further using :attr:`.Form.bound_field_class`
+        for per-form overrides, or :attr:`.Field.bound_field_class` for
+        per-field overrides.
+
     .. method:: get_template(template_name)
 
         Subclasses must implement this method with the appropriate template
diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt
index a65306d0a19b..5b04608cad34 100644
--- a/docs/releases/5.2.txt
+++ b/docs/releases/5.2.txt
@@ -70,6 +70,62 @@ to be a ``CompositePrimaryKey``::
 
 See :doc:`/topics/composite-primary-key` for more details.
 
+Simplified override of :class:`~django.forms.BoundField`
+--------------------------------------------------------
+
+Prior to version 5.2, overriding :meth:`.Field.get_bound_field()` was the only
+option to use a custom :class:`~django.forms.BoundField`. Django now supports
+specifying the following attributes to customize form rendering:
+
+* :attr:`.BaseRenderer.bound_field_class` at the project level,
+* :attr:`.Form.bound_field_class` at the form level, and
+* :attr:`.Field.bound_field_class` at the field level.
+
+For example, to customize the ``BoundField`` of a ``Form`` class::
+
+    from django.forms import Form
+
+
+    class CustomBoundField(forms.BoundField):
+
+        custom_class = "custom"
+
+        def css_classes(self, extra_classes=None):
+            result = super().css_classes(extra_classes)
+            if self.custom_class not in result:
+                result += f" {self.custom_class}"
+            return result.strip()
+
+
+    class CustomForm(forms.Form):
+        bound_field_class = CustomBoundField
+
+        name = forms.CharField(
+            label="Your Name",
+            max_length=100,
+            required=False,
+            widget=forms.TextInput(attrs={"class": "name-input-class"}),
+        )
+        email = forms.EmailField(label="Your Email")
+
+
+When rendering a ``CustomForm`` instance, the following HTML is included:
+
+.. code:: html
+
+    <div class="custom">
+      <label for="id_name">Your Name:</label>
+      <input type="text" name="name" class="name-input-class" maxlength="100" id="id_name">
+    </div>
+
+    <div class="custom">
+      <label for="id_email">Your Email:</label>
+      <input type="email" name="email" maxlength="320" required="" id="id_email">
+    </div>
+
+
+See :ref:`custom-boundfield` for more details about this feature.
+
 Minor features
 --------------
 


Test patch:
diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py
index b41424d43d64..20c86754c6e5 100644
--- a/tests/forms_tests/tests/test_forms.py
+++ b/tests/forms_tests/tests/test_forms.py
@@ -8,6 +8,7 @@
 from django.core.validators import MaxValueValidator, RegexValidator
 from django.forms import (
     BooleanField,
+    BoundField,
     CharField,
     CheckboxSelectMultiple,
     ChoiceField,
@@ -4971,6 +4972,22 @@ def test_get_context_errors(self):
         context = form.get_context()
         self.assertEqual(context["errors"].renderer, custom)
 
+    def test_boundfield_fallback(self):
+        class RendererWithoutBoundFieldClassAttribute:
+            form_template_name = "django/forms/div.html"
+            formset_template_name = "django/forms/formsets/div.html"
+            field_template_name = "django/forms/field.html"
+
+            def render(self, template_name, context, request=None):
+                return "Nice"
+
+        class UserForm(Form):
+            name = CharField()
+
+        form = UserForm(renderer=RendererWithoutBoundFieldClassAttribute())
+        self.assertIsInstance(form["name"], BoundField)
+        self.assertEqual(form["name"].as_field_group(), "Nice")
+
 
 class TemplateTests(SimpleTestCase):
     def test_iterate_radios(self):
@@ -5473,3 +5490,146 @@ class CustomFrameworkForm(FrameworkForm):
             '<label for="id_name" class="required">Name:</label>'
             '<legend class="required">Language:</legend>',
         )
+
+
+class BoundFieldWithoutColon(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        return super().label_tag(
+            contents=contents, attrs=attrs, label_suffix="", tag=None
+        )
+
+
+class BoundFieldWithTwoColons(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        return super().label_tag(
+            contents=contents, attrs=attrs, label_suffix="::", tag=None
+        )
+
+
+class BoundFieldWithCustomClass(BoundField):
+    def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+        attrs = attrs or {}
+        attrs["class"] = "custom-class"
+        return super().label_tag(contents, attrs, label_suffix, tag)
+
+
+class BoundFieldWithWrappingClass(BoundField):
+    def css_classes(self, extra_classes=None):
+        parent_classes = super().css_classes(extra_classes)
+        return f"field-class {parent_classes}"
+
+
+class BoundFieldOverrideRenderer(DjangoTemplates):
+    bound_field_class = BoundFieldWithoutColon
+
+
+@override_settings(
+    FORM_RENDERER="forms_tests.tests.test_forms.BoundFieldOverrideRenderer"
+)
+class CustomBoundFieldTest(SimpleTestCase):
+    def test_renderer_custom_bound_field(self):
+        t = Template("{{ form }}")
+        html = t.render(Context({"form": Person()}))
+        expected = """
+            <div><label for="id_first_name">First name</label>
+            <input type="text" name="first_name" required
+            id="id_first_name"></div>
+            <div><label for="id_last_name">Last name</label>
+            <input type="text" name="last_name" required
+            id="id_last_name"></div><div>
+            <label for="id_birthday">Birthday</label>
+            <input type="text" name="birthday" required
+            id="id_birthday"></div>"""
+        self.assertHTMLEqual(html, expected)
+
+    def test_form_custom_boundfield(self):
+        class CustomBoundFieldPerson(Person):
+            bound_field_class = BoundFieldWithTwoColons
+
+        with self.subTest("form's BoundField takes over renderer's BoundField"):
+            t = Template("{{ form }}")
+            html = t.render(Context({"form": CustomBoundFieldPerson()}))
+            expected = """
+                <div><label for="id_first_name">First name::</label>
+                <input type="text" name="first_name" required
+                id="id_first_name"></div>
+                <div><label for="id_last_name">Last name::</label>
+                <input type="text" name="last_name" required
+                id="id_last_name"></div><div>
+                <label for="id_birthday">Birthday::</label>
+                <input type="text" name="birthday" required
+                id="id_birthday"></div>"""
+            self.assertHTMLEqual(html, expected)
+
+        with self.subTest("Constructor argument takes over class property"):
+            t = Template("{{ form }}")
+            html = t.render(
+                Context(
+                    {
+                        "form": CustomBoundFieldPerson(
+                            bound_field_class=BoundFieldWithCustomClass
+                        )
+                    }
+                )
+            )
+            expected = """
+                <div><label class="custom-class" for="id_first_name">First name:</label>
+                <input type="text" name="first_name" required
+                id="id_first_name"></div>
+                <div><label class="custom-class" for="id_last_name">Last name:</label>
+                <input type="text" name="last_name" required
+                id="id_last_name"></div><div>
+                <label class="custom-class" for="id_birthday">Birthday:</label>
+                <input type="text" name="birthday" required
+                id="id_birthday"></div>"""
+            self.assertHTMLEqual(html, expected)
+
+        with self.subTest("Overriding css_classes works as expected"):
+            t = Template("{{ form }}")
+            html = t.render(
+                Context(
+                    {
+                        "form": CustomBoundFieldPerson(
+                            bound_field_class=BoundFieldWithWrappingClass
+                        )
+                    }
+                )
+            )
+            expected = """
+                <div class="field-class"><label for="id_first_name">First name:</label>
+                <input type="text" name="first_name" required
+                id="id_first_name"></div>
+                <div class="field-class"><label for="id_last_name">Last name:</label>
+                <input type="text" name="last_name" required
+                id="id_last_name"></div><div class="field-class">
+                <label for="id_birthday">Birthday:</label>
+                <input type="text" name="birthday" required
+                id="id_birthday"></div>"""
+            self.assertHTMLEqual(html, expected)
+
+    def test_field_custom_bound_field(self):
+        class BoundFieldWithTwoColonsCharField(CharField):
+            bound_field_class = BoundFieldWithTwoColons
+
+        class CustomFieldBoundFieldPerson(Person):
+            bound_field_class = BoundField
+
+            first_name = BoundFieldWithTwoColonsCharField()
+            last_name = BoundFieldWithTwoColonsCharField(
+                bound_field_class=BoundFieldWithCustomClass
+            )
+
+        html = Template("{{ form }}").render(
+            Context({"form": CustomFieldBoundFieldPerson()})
+        )
+        expected = """
+            <div><label for="id_first_name">First name::</label>
+            <input type="text" name="first_name" required
+            id="id_first_name"></div>
+            <div><label class="custom-class" for="id_last_name">Last name:</label>
+            <input type="text" name="last_name" required
+            id="id_last_name"></div><div>
+            <label for="id_birthday">Birthday:</label>
+            <input type="text" name="birthday" required
+            id="id_birthday"></div>"""
+        self.assertHTMLEqual(html, expected)