Long forms hurt conversion. When a user has to fill in fifteen fields on one screen – contact info, company details, address, requirements – they either bail halfway or make mistakes trying to rush through them. The common fix is to split the form into logical steps: “Basic Information,” “Company Details,” “Location,” “Requirements” – and let the user move through them one at a time.
Horilla CRM ships this as a built-in framework feature, HorillaMultiStepForm, which sits right alongside the familiar HorillaModelForm. If you already know how to build a HorillaModelForm, building a multi-step version is a small extension of the same pattern – you’re just telling Horilla which fields belong on which step, and the base class handles hiding fields, per-step validation, session persistence, and rendering the step progress bar for you.
1. Where it lives
from horilla.contrib.generics.forms import HorillaMultiStepForm
from horilla.contrib.generics.views import HorillaMultiStepFormView
- HorillaMultiStepForm (horilla/contrib/generics/forms/multi_step.py) — the form class. It’s built on HorillaFormMixin + forms.ModelForm, the same mixin HorillaModelForm uses, so all the usual Meta options (model, fields, exclude, keep_on_form) work the same way.
- HorillaMultiStepFormView (horilla/contrib/generics/views/multi_form.py) — the view class that drives step navigation, stores in-progress data in the session, and renders the wizard UI.
2. Defining the form: step_fields
The only thing that makes a multi-step form different from a regular HorillaModelForm is one extra attribute: step_fields, a dict mapping a step number to the list of field names shown on that step.
Here’s the real form used for Leads in Horilla CRM:
class LeadFormClass(OwnerQuerysetMixin, HorillaMultiStepForm):
"""Form class for Lead model"""
class Meta:
model = Lead
fields = "__all__"
exclude = ["lead_score"]
step_fields = {
1: [
"lead_owner", "title", "first_name", "last_name",
"email", "contact_number", "fax",
"lead_source", "lead_status",
],
2: ["lead_company", "no_of_employees", "industry", "annual_revenue"],
3: ["country", "state", "city", "zip_code"],
4: ["requirements"],
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["lead_status"].queryset = LeadStatus.objects.filter(
is_final=False
)
self.fields["country"].widget.attrs.update({
"hx-get": reverse_lazy("core:get_country_subdivisions"),
"hx-target": "#id_state",
"hx-trigger": "change",
})
That’s it four steps, grouped by what makes sense to the user: identity fields, company fields, location fields, and finally free-text requirements. You still customize field behavior in __init__ exactly like you would in a normal HorillaModelForm (queryset filtering, HTMX attributes for dependent dropdowns, etc.) HorillaMultiStepForm doesn’t get in the way of that.
A couple of things the base class takes care of automatically so you don’t have to think about them:
- Fields you forget to assign to a step are automatically appended to the last step, so fields = “__all__” still works without listing every single field explicitly.
- Fields not on the current step aren’t removed from the form – they’re hidden (HiddenInput) and marked not-required, so their previously submitted values still round-trip through the session between steps.
- Validation is scoped per step. clean() only enforces “required” and other errors for fields on the current step, so submitting step 1 doesn’t fail because step 4’s requirements field is empty.
3. Defining the view
class LeadFormView(LoginRequiredMixin, HorillaMultiStepFormView):
"""Lead Create/Update View"""
form_class = LeadFormClass
model = Lead
fullwidth_fields = ["requirements"]
dynamic_create_fields = ["lead_status"]
detail_url_name = "leads:leads_detail"
step_titles = {
"1": _("Basic Information"),
"2": _("Company Details"),
"3": _("Location"),
"4": _("Requirements"),
}
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["request"] = self.request
return kwargs
- form_class / model : same as any Horilla form view.
- step_titles : labels shown in the progress stepper at the top of the form, keyed by step number (as a string).
- fullwidth_fields : fields that should span the full form width (useful for a textarea like requirements).
- dynamic_create_fields : fields where the user can create a new related object inline (e.g. a new LeadStatus) without leaving the form.
Behind the scenes, HorillaMultiStepFormView:
- Reads which step is being submitted from a hidden step POST field.
- Stores partially-filled data in the session between steps, so nothing is lost as the user moves forward or clicks “back.”
- On the final step’s successful validation, actually saves the model instance.
- Renders everything through the shared form_view.html template, which is HTMX-powered (hx-post, hx-swap=”outerHTML“) so each step swaps in without a full page reload.
4. Wiring up the URLs
Nothing special here — HorillaMultiStepFormView is a normal class-based view:
# horilla_crm/leads/urls.py
path(
"leads-create/",
views.LeadFormView.as_view(),
name="leads_create",
)
path(
"leads-edit/<int:pk>/",
views.LeadFormView.as_view(),
name="leads_edit",
)
The same view class handles both create and edit – passing a pk in the URL switches it into edit mode, just like HorillaSingleFormView.
5. Letting users choose single-step vs. multi-step (optional)
Horilla’s Lead form goes one step further and lets the form offer both a single-step and multi-step version of the same model, toggled from the UI:
form_mode = [
{
"title": _("Single-Step Form"),
"url_name": {
"create": "leads:leads_create_single",
"edit": "leads:leads_edit_single",
},
"active": False,
},
{
"title": _("Multi-Step Form"),
"url_name": {
"create": "leads:leads_create",
"edit": "leads:leads_edit",
},
"active": True,
},
]
This isn’t required to use HorillaMultiStepForm – it’s just a nice pattern if you want to give users the choice between a quick single-page form (HorillaModelForm + HorillaSingleFormView) and a guided wizard, backed by the same model.
HorillaMultiStepForm makes it simple to divide large forms into clear, manageable steps without handling the form state and validation logic manually. By defining step_fields and configuring the corresponding HorillaMultiStepFormView, Horilla takes care of step navigation, session data, validation, and rendering. This keeps multi-step forms consistent with the rest of the framework while making complex forms easier for users to complete.