How We Built It, Part 6: The Forms

The membership webform — the single "How would you like to connect?" intent question that adapts the rest of the form.

In Part 5: The Calendar we taught the site to render time. Now we teach it to listen. Every real site eventually needs the same thing: a form that collects something from a visitor and does something useful with it. This post walks the full path a submission takes — design → validate → store → email — using the three forms that ship with Chattanooga.Digital.

Webform, not a plugin you rent

Drupal core has a Contact module: one fixed form, a fixed set of fields, emails the site admin. Fine for a single "info@" inbox, painful the moment you need a second form. So we use Webform — the community-standard module for arbitrary forms with arbitrary fields, conditional logic, multi-value composites, submission storage, and email routing, all native and all free.

The thing that makes Webform deploy-friendly — and the reason it fits this whole series — is that a form is just configuration. Each form is a single YAML file you can read, diff, and check into git. Our three live at:

recipes/chattanooga_digital/config/webform.webform.prejoin.yml
recipes/chattanooga_digital/config/webform.webform.membership.yml
recipes/chattanooga_digital/config/webform.webform.tech_strategy.yml

The recipe installs the module by listing it in recipe.yml under install:, and the three config files come along for the ride. No clicking required to stand a form back up on a fresh build — that's the whole point of recipes from Part 2.

The membership form: one form, three intents

The simplest of the three is prejoin — a single email field on the homepage for "tell me when the co-op opens." The interesting one is membership, served at /membership. Rather than build three separate forms for "tell me more," "send me updates," and "I want to join," it asks one question up front and adapts:

intent:
  "#type": radios
  "#title": "How would you like to connect?"
  "#required": true
  "#options":
    more_info: "I would like more information."
    updates: "Send me updates via email."
    join: "Yes! I want to join Chattanooga.Digital."

Everyone fills in first name, last name, and email. But the long "membership details" section — honorific, organization, background, experience, interests, address — only appears for people who picked join. That's Webform's conditional states: a field declares when it should be visible (or required) based on another field's value, evaluated in the browser and re-checked on the server:

join_details:
  "#type": details
  "#states":
    visible:
      ':input[name="intent"]':
        value: join

The same mechanism makes the background field required only for joiners, and reveals the organization name only when the "applying on behalf of an organization" box is checked. One form, three experiences, zero JavaScript we had to write.

Email routing: the right inbox per intent

Storage is automatic — every submission lands in the database and is reviewable under /admin/structure/webform. The active step is the email. Webform attaches handlers to a form, and a handler can be conditional on a field value, so the membership form has three email handlers gated on the same intent answer:

email_join:
  id: email
  conditions:
    enabled:
      ':input[name="intent"]':
        value: join
  settings:
    to_mail: Membership@Chattanooga.Digital
    subject: 'New membership application from [webform_submission:values:first_name:raw] ...'

The bracketed strings are tokens — Webform substitutes the submitted values at send time, so the subject line carries the applicant's name and the body carries their answers. All three intents route to Membership@Chattanooga.Digital, but each sends a differently-framed message. Locally those emails land in Mailpit at localhost:8025 so you can read them without sending real mail; in production the smtp module relays through Fastmail (more on that in Part 8).

The Strategic Planning Tool: 80 fields and two custom autocompletes

The third form, tech_strategy (served at /tech-strategy), is where Webform earns its keep. It's a structured organizational self-assessment — roughly 80 fields across collapsible sections covering an organization's stakeholders, strategy, tech stack, and value-chain activities. Two features carry it.

Composite multi-value fields. Most of the tool is repeating rows — "list each major goal," "add another objective," "add another technology." Webform's webform_custom_composite element bundles several sub-inputs into one row and lets the visitor add as many rows as they need:

major_goals_list:
  '#type': webform_custom_composite
  '#multiple__add_more_input_label': 'Add another goal'
  '#element':
    goal_id:
      '#type': select
      '#title': 'ID'
    goal_text:
      '#type': textfield
      '#title': 'Goal'

There are 33 of these composites in the tool. Each renders as a tidy table the visitor grows row by row, and each row stores as structured data — not a free-text blob — so the results stay analyzable.

Custom autocomplete endpoints. Two fields ask for industry and product classifications, and rather than make a non-expert memorize codes, the form autocompletes against real public-domain reference data. We wrote two tiny custom modules for this — cd_naics_autocomplete (2017 NAICS industry codes) and cd_unspsc_autocomplete (UNSPSC product/service codes) — each exposing one route:

cd_naics_autocomplete.autocomplete:
  path: '/naics/autocomplete'
  defaults:
    _controller: '\Drupal\cd_naics_autocomplete\Controller\NaicsAutocompleteController::handle'
  requirements:
    _access: 'TRUE'

A Webform field opts into it with a single line — no widget code, no front-end build:

organization_sector:
  '#type': textfield
  '#title': 'Sector'
  '#autocomplete_route_name': cd_naics_autocomplete.autocomplete

The controller reads its query from the q parameter, matches against a JSON dataset shipped in the module's data/ directory (the NAICS list is ~500 KB; UNSPSC nearly 2 MB), and returns the {value, label} shape Drupal's autocomplete widget expects — the code lands in the field, "code — title" shows in the dropdown. The same one-line pattern wires UNSPSC onto the "Product Type" sub-field inside the Key Technologies composite. Because the endpoints only read static reference data, anonymous access is correct and intentional.

Keeping the bots out: the honeypot

An anonymous form open to the whole internet is a spam magnet, and a 30-second open submission window is a bot's favorite kind. We arm all three open forms with the honeypot module — no CAPTCHA puzzle for real humans, two server-validated traps for bots:

  • An invisible field. Honeypot renders a hidden input a human never sees; automated scripts fill in every field they find, so a non-empty value silently drops the submission.
  • A timing gate. A 5-second floor between page load and submit. Bots post instantly; people don't.

Protection is per-form opt-in, set as a config action in the recipe so it's reproducible on every build:

honeypot.settings:
  simpleConfigUpdate:
    form_settings:
      webform_submission_membership_add_form: true
      webform_submission_prejoin_add_form: true
      webform_submission_tech_strategy_add_form: true

One subtlety worth knowing if you ever test these: the form ID suffix is _add_form (Drupal's pattern for a "create" submission), not _form. And honeypot adaptively raises the time gate for a same-IP visitor who trips a trap — 5 seconds becomes minutes — so a happy-path test run right after a deliberate trip-test can fail with "Please wait N seconds." That behavior is the feature working; clear honeypot's flood state between runs if you need a clean slate. The membership form's spam rule is integration-tested in tests/e2e/membership.spec.ts, so a future content re-import can't silently turn the armor off.

What's next

The forms now collect, validate, store, and email — the full path, all from version-controlled YAML. But several of these features only matter because something runs them on a schedule: the calendar re-imports feeds, backups ship off-site, security notices fire. In Part 7 we look at the scheduler that keeps the site alive between visits.

Beta-test this post with your AI

This series is open-source documentation, and documentation drifts. Help us keep it honest: point your AI coding assistant (Cursor, Claude Code, or similar) at this post and the repo, and if it finds something that no longer matches the code, open a ticket. First time? Read A Token Is an Identity (in this series) so your AI opens issues as you, with credit. Edit the three variables at the top of the block below, then paste it into your assistant:

# ─────── EDIT THESE FIRST ───────
GITHUB_USERNAME = "your-github-username"   # your handle, so the issue is credited to YOU (see the Token Is an Identity post)
DEMO_REPO       = "TortoiseWolfe/gig-city-drupal"   # the repo to file against — or your own fork: GITHUB_USERNAME/gig-city-drupal
ARTICLE_URL     = "https://www.chattanooga.digital/blog/2026-06/how-we-built-it-part-6-forms"
# ────────────────────────────────

Read ARTICLE_URL, then clone DEMO_REPO into my local environment and explore it.

Cross-check Part 6 (The Forms) against the actual code — the webform fields, the membership/contact forms, and the spam protection.

If anything is inaccurate, out of date, or contradicts the code, open an issue on
DEMO_REPO (authenticated as GITHUB_USERNAME) so the maintainers can fix the tutorial.

A good issue:
  - one problem per issue (not a catch-all list)
  - a clear title naming the section, e.g. "Part N:  no longer matches the code"
  - cite the exact spot: the post section + the repo file/path (and line if you can)
  - say what the post claims vs. what the code actually does
  - suggest the fix if it's obvious

← Part 5: The Calendar  ·  Part 7: Cron + Automation →

More insights

Want updates from Chattanooga.Digital?

Pre-join the co-op to receive new posts, workshop schedules, and member updates.