The rest of this series has shown _inherit_detail, _inherit_card, and _inherit_kanban extending views pages that get dispatched by URL routing. Forms are different: a form isn’t routed to directly, it’s instantiated inside a view’s get_form_class()/get_form() lifecycle. So while _inherit_form follows the same declare-a-spec, compose-a-mixin pattern as every other extension type, the mechanics of where composition plugs in, and what it’s capable of merging, are both worth walking through on their own.

This is also the richest extension type in the framework. Where view extensions mostly append to lists of field names, form extensions can declare real Django form fields, merge Meta options, reorder fields, and inject per-step fields into a multi-step wizard, and Horilla CRM‘s own custom_fields app relies on every one of these capabilities to inject dynamic custom fields into arbitrary forms across the whole CRM.

The core idea

# horilla_crm/compliance/form_extensions.py

from django import forms
from horilla.extension.forms import FormExtension


class LeadComplianceFormExtension(FormExtension):
    """Adds a compliance acknowledgement field to the Lead create/edit form."""


    _inherit_form = "horilla_crm.leads.forms.LeadSingleForm"


    compliance_ack = forms.BooleanField(required=True, label="Compliance acknowledged")


    field_order_append = ["compliance_ack"]


    class Meta:
        widgets = {"compliance_ack": forms.CheckboxInput(attrs={"class": "cb-switch"})}


    def setup_form_extension_fields(self):
        """Runs after the target form builds its fields — safe to touch self.fields."""
        if self.instance and self.instance.pk:
            self.fields["compliance_ack"].initial = self.instance.is_compliance_flagged


    def clean(self):
        cleaned = super().clean()
        if cleaned.get("compliance_ack") is False:
            self.add_error("compliance_ack", "Acknowledgement is required.")
        return cleaned


    def save(self, commit=True):
        instance = super().save(commit=False)
        instance.is_compliance_flagged = not self.cleaned_data["compliance_ack"]
        if commit:
            instance.save()
        return instance

Set _inherit_form and the field, method, and Meta contributions above are all that’s needed no explicit registration call, no subclassing LeadSingleForm, no import of it either.

What gets captured from your class

register_extension_class() reads four kinds of contributions:

  • Declared fields — any forms.Field instance assigned directly on the class body (compliance_ack = forms.BooleanField(…) above). These get added to the composed form exactly like fields declared on the original form class.
  • Layout hooks — field_order_insert / field_order_append, step_fields_insert / step_fields_append (multi-step only), fieldsets_insert.
  • Method overrides — clean, save, setup_form_extension_fields, or any other plain method.
  • Meta contributions — a nested class Meta whose exclude, keep_on_form, widgets, labels, help_texts, error_messages, and fields_append all get merged into the composed form’s own Meta, rather than replacing it.

How composition merges all of this

compose_form_class() does more merging work than any other composer in the extension system:

  • Declared fields are collected from every extension spec. If two extensions declare the same field name, that’s an error unless one of them explicitly opts in via override_fields = (“field_name“,) — this exists specifically to prevent two unrelated apps from silently clobbering each other’s field.
  • Meta is rebuilt: exclude and keep_on_form union together across every extension, widgets/labels/help_texts/error_messages dict-merge, and fields_append is unioned into Meta.fields when it’s an explicit list (not “__all__“).
  • field_order merges via insert-after-anchor or append, same semantics as the layout hooks in view extensions.
  • step_fields — multi-step-only merges per step, so an extension can insert a field into step 2 of a wizard without needing to know about any other step.
  • fieldsets merges with the same insert-after-anchor logic used by detail view fieldsets_insert.

As with the other extension types, the result is built with type(): ComposedName(Mixin1, …, Target), so super().clean() / super().save() written in your extension correctly chains into the next extension or the original form.

A subtlety worth knowing: setup_form_extension_fields always runs, even with multiple extensions

Every form mixin gets its own generated __init__ that calls super(mixin, self).__init__(…) and then its own setup_form_extension_fields. This matters because if two apps both extend the same form, each one’s hook needs to run exactly once a naive self.setup_form_extension_fields() call would only ever find the first extension’s version in the MRO, silently skipping every other one. The composer reads each mixin’s own (already super()-rebound) function directly off the mixin and calls it explicitly, so this failure mode can’t happen regardless of how many apps extend the same form.

This is exactly the mechanism Horilla’s own custom_fields app depends on: it discovers every HorillaModelForm/HorillaMultiStepForm whose model opted into custom fields, and registers one FormExtension per form with a setup_form_extension_fields that injects that model’s cf_* fields at request time (deliberately not at class-definition time, since which custom fields exist is dynamic and per-company).

Where composition actually plugs in

Unlike view extensions, there’s no as_view() hook here forms don’t get dispatched by URL. Instead, HorillaSingleFormView.get_form_class() (and the multi-step equivalent) call resolve_form_class() directly:

def get_form_class(self):
    base = super().get_form_class()
    if base is None:
        return base
    from horilla.extension.forms.resolve import resolve_form_class
    return resolve_form_class(base)

The view still holds form_class = LeadSingleForm at import time, as written in its own source. resolve_form_class() swaps in the composed LeadSingleFormExtended only when the form is actually instantiated for a request which is why, just like the view extensions, your extension app can load after the target CRM app without missing anything: resolution happens per-request, not at import time.

Discovering forms dynamically instead of hardcoding paths

If your extension needs to reach every form for a set of models (not one specific form you already know the path to), register a pre-compose hook instead of hardcoding _inherit_form. custom_fields/extensions/forms.py is the reference example: it walks every installed app’s forms module for HorillaModelForm/HorillaMultiStepForm subclasses, checks each form’s Meta.model against a feature registry, and dynamically builds a FormExtension subclass per match, registering the hook via:

from horilla.extension._pre_compose_hooks import register_pre_compose_hook

register_pre_compose_hook(“forms”, register_discovered_form_extensions)

This hook reruns at the start of every apply_form_extensions() call (the same compose step every form resolution already triggers), so a CRM app that opts a model into your feature after your extension app has loaded still gets picked up with no reliance on INSTALLED_APPS ordering.

Form extensions follow the same declare-don’t-subclass contract as every other extension type in Horilla, but with meaningfully more surface area: real declared fields, full Meta merging, field ordering, and per-step field injection for wizards. The one structural difference to keep in mind is with resolution happens not in as_view(), but inside the view’s own get_form_class() and the one correctness detail worth internalizing is that setup_form_extension_fields is guaranteed to run once per extension, which is exactly what makes it safe for more than one unrelated app to extend the same form without stepping on each other’s.

Share this article