Using form--[form-id].html.twig for custom formsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

Every form in Drupal renders through the exact same base template — a generic form.html.twig that has no idea whether it's rendering a login form, a contact form, or your own custom one. That's fine for 99% of forms, but sometimes one specific form deserves its own layout without touching any other form on the site. Drupal's answer is the same targeted-override convention you'll recognize from elsewhere in theming: append the form's machine ID, with dashes instead of underscores, and Drupal will pick your file over the generic one automatically — for that form, and only that form.

What you'll learn in this lesson

  • How to find a form's exact machine ID so you can target it precisely
  • The naming convention that turns a form ID into a template filename
  • How to selectively print or reorganize a form's pieces instead of dumping the whole thing as one block
  • Why this override is completely isolated from every other form on the site

Finding the form ID

A form's ID is whatever string its getFormId() method returns — for example, a class might return 'contact_message_feedback_form'. If you don't have the source handy, Drupal's theme debugging mode (enable it in services.yml under twig.config: debug: true) prints an HTML comment directly in the page source listing every template Drupal considered for that element, in order of specificity — the form ID is right there in that list.

Creating the form-specific template

Once you have the ID, the filename is mechanical: replace every underscore with a dash, prefix with form--, and drop it in your theme's templates directory.

{# templates/form--contact-message-feedback-form.html.twig #}
<div class="feedback-form-wrapper">
  {{ form|without('form_build_id', 'form_token', 'form_id') }}
</div>

The without() filter is worth pausing on: form_build_id, form_token, and form_id are hidden elements Drupal adds to every form for state-tracking and CSRF protection — printing {{ form }} directly would still render them (harmlessly, as hidden inputs), but excluding them from the explicit dump keeps your template's intent clear: you're deciding what's visible, not just wrapping whatever Drupal happens to include.

Rearranging individual elements instead of printing the whole form

Printing {{ form }} whole is the simplest option, but the real power of a form-specific template is picking elements out by name and placing them exactly where you want:

{# templates/form--contact-message-feedback-form.html.twig #}
<div class="feedback-form-wrapper">
  <div class="feedback-form-header">
    {{ form.name }}
    {{ form.email }}
  </div>
  <div class="feedback-form-body">
    {{ form.message }}
  </div>
  {{ form.actions }}
  {{ form|without('name', 'email', 'message', 'actions', 'form_build_id', 'form_token', 'form_id') }}
</div>

Each {{ form.element_name }} reference prints exactly one element, letting you split a flat form into a two-column header and a body section — a layout no render array by itself could express, since render arrays describe structure and order, not arbitrary visual grouping. The final without() call at the bottom is a safety net: it prints anything you forgot to reference explicitly (a validation-added element, for instance) rather than silently dropping it.

This override is completely isolated. Unlike a preprocess hook or an input--TYPE.html.twig override (which can affect every form or every field of that type across the whole site), a form--[form-id].html.twig file only ever applies to the one form whose ID matches its filename. You can be as aggressive as you want restructuring it without any risk of breaking an unrelated form elsewhere on the site.

Quick check: you've created form--contact-message-feedback-form.html.twig, cleared the cache, and the form still renders with the old generic layout. What's the single most likely cause? Almost always a form-ID mismatch — either the underscore-to-dash conversion was done wrong, or the ID you found wasn't the actual `getFormId()` return value for that specific request (some forms, like multi-step wizards, can return a different ID depending on the current step). Re-check with theme debugging enabled before assuming anything else is broken.

Key takeaways

  • Template filename convention: take the form's exact ID string, replace every underscore with a dash, prefix with form--.
  • Enable Twig debug mode (twig.config: debug: true in services.yml) to see the exact list of template names Drupal considered for any given form, directly in the page source.
  • Use {{ form.element_name }} to pull out and reposition individual elements; use {{ form|without(...) }} at the end to catch anything you didn't reference explicitly.
  • A form-ID-specific template affects only that one form — it's the safest, most isolated override available, unlike a global input--TYPE.html.twig or a preprocess hook.
  • Always clear the cache after adding or renaming a template file, and if it still doesn't apply, suspect a form-ID mismatch first.

Coming up next

Templates give you full structural control, but sometimes all you need is a CSS class on a specific element — no new file required. The next lesson covers adding custom CSS classes directly through the render array, using #attributes, without touching Twig at all.