Oops, something went wrong! Site admins have been notified.

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 Greg Laudeman's structured 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.

← 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.