Skip to content

Formulare ​

Das Frontend kennt genau eine Funktion zum Absenden. Welches Backend dahinter steht, entscheidet die Projektkonfiguration — nicht die Seite, auf der das Formular steht.

ts
forms: {
  provider: 'standalone-php',        // 'none' | 'standalone-php' | 'joomla'
  endpoint: '/api/forms/submit.php',
  captcha: 'turnstile',              // 'none' | 'turnstile'
}

Ein Providerwechsel ist damit eine Änderung an zwei Zeilen und an keiner einzigen Seite. Die Begründung steht in ADR 0009.

ProviderWofür
standalone-phpDer Regelfall: Kontakt, Rückruf, Bewerbung. Zum Handler
joomlaSpeichern, Verwalten, Exportieren. Zum Provider
noneKein Backend — <Form /> gibt nichts aus

Ohne Backend entsteht kein Formular

Steht provider auf none, gibt <Form /> nichts aus. Ein Kontaktformular, das ins Leere sendet, ist die unangenehmste Art, eine Zusage zu brechen: Der Absender hält seine Anfrage für gestellt und wartet auf eine Antwort, die nie kommt. Was eine Seite stattdessen anbietet, entscheidet sie selbst — src/pages/kontakt.astro zeigt die direkten Kontaktwege.

Ein Formular anlegen ​

Es sind zwei Schritte, und sie haben verschiedene Aufgaben.

1. Die Allowlist ​

src/content/forms.ts sagt, welche Felder das Formular annimmt. Mehr nicht — keine Beschriftungen, keine Reihenfolge, kein Raster.

ts
export const forms = defineForms([
  {
    id: 'kontakt',
    schemaVersion: 1,
    fields: [
      { name: 'name', type: 'text', required: true, maxLength: 120 },
      { name: 'email', type: 'email', required: true, maxLength: 254 },
      { name: 'anliegen', type: 'select', required: true, options: ['beratung', 'angebot'] },
      { name: 'nachricht', type: 'textarea', required: true, maxLength: 4000 },
      { name: 'datenschutz', type: 'checkbox', required: true, consent: 'privacy' },
    ],
  },
]);
FeldBedeutung
namelowerCamelCase. Wird zum JSON-Schlüssel und in PHP zum Array-Schlüssel.
typeSteuert die Prüfung, nicht das Aussehen. email heißt „muss eine Adresse sein".
requiredPflichtfeld. Muss im Markup als required erscheinen — ein Test hält das fest.
maxLengthNur bei Textfeldern. Ohne Angabe gilt die Grenze des Vertrags (5000).
optionsBei select, radio und checkboxes. Alles andere lehnt das Backend ab.
consent'privacy' oder 'marketing'. Siehe unten.

schemaVersion erhöhen

Jede Änderung an fields verlangt eine höhere schemaVersion. Sie geht mit jeder Übermittlung hinaus und ist der Griff, mit dem ein Backend eine veraltete, im Browser-Cache hängengebliebene Seite von einer aktuellen unterscheidet.

2. Das Markup ​

Die Felder schreibt die Seite von Hand, mit den Primitives aus src/components/ui/ und in der Anordnung, die sie braucht:

astro
---
import Form from '../components/forms/Form.astro';
import Input from '../components/ui/Input.astro';
import Checkbox from '../components/ui/Checkbox.astro';
---

<Form id="kontakt" submitLabel="Anfrage senden">
  <Input name="name" label="Name" autocomplete="name" required maxlength={120} />
  <Input name="email" type="email" label="E-Mail" autocomplete="email" required maxlength={254} />
  <Checkbox name="datenschutz" required>
    Ich habe die <a href="/datenschutz">Datenschutzerklärung</a> gelesen.
  </Checkbox>
</Form>

<Form /> liefert alles darum herum: Honeypot, Captcha, Fehlerübersicht, Statusmeldung, Absende-Schaltfläche und die Verbindung zu submitForm().

Dass beide Seiten zusammenpassen, prüft tests/dist/forms.test.ts am ausgelieferten HTML: Ein Feld im Markup ohne Eintrag in der Allowlist bricht die Kette ab, statt erst beim ersten echten Absender aufzufallen.

Die Einwilligung ​

Das Ankreuzfeld mit consent: 'privacy' ist ein Sonderfall. Es steht in der Allowlist — damit der Browser es prüft und das Markup dagegen abgeglichen werden kann —, wird aber nicht unter fields mitgeschickt. Die Laufzeit verschiebt den Wert in den Bereich consent des Vertrags, wo er einen Zeitstempel bekommt: Dort ist er keine Angabe zur Anfrage, sondern die Grundlage ihrer Verarbeitung.

Regeln, die der Build erzwingt:

  • Genau ein Feld je Formular trägt consent: 'privacy', und es ist required.
  • Ein Feld mit consent: 'marketing' darf nicht required sein. Eine erzwungene Werbeeinwilligung ist keine.
  • Beide müssen Ankreuzfelder sein.

Der Vertrag ​

Quelle ist src/lib/forms/contract.ts (Zod). Daraus entstehen die TypeScript-Typen und die sprachneutrale Fassung unter contract/form-submission.schema.json, die der PHP-Handler und die Joomla-Erweiterung lesen.

bash
pnpm forms:contract   # schreibt Vertrag und Allowlist neu

Derselbe Befehl schreibt die Feld-Allowlist aus src/content/forms.ts in der Fassung, die ein Backend lesen kann — contract/forms.allowlist.json in jedem Projekt, und zusätzlich server/forms/config/forms.json, wenn der PHP-Handler mitgeliefert wird. Die zweite Fassung steht nicht da, weil zwei besser wären, sondern weil sie mit dem Handler auf den Server wandert; erzeugt werden beide aus derselben Quelle mit demselben Befehl (ADR 0018). Je ein Test bricht ab, sobald eine der Dateien veraltet ist. Die Diffs gehören in denselben Commit wie die Änderung — sie sind die Stelle, an der eine Durchsicht merkt, dass gerade zwei Systeme auseinandergehen.

Der gefährlichere der beiden Fälle

Läuft der Vertrag auseinander, lehnt das Backend jede Übermittlung ab — das fällt sofort auf. Läuft die Allowlist auseinander, nimmt es die Anfrage an und lässt genau das Feld weg, das jemand eingetippt hat: Die Anfrage kommt an, die Rückrufnummer fehlt.

Fehlercodes ​

CodeHTTPBedeutung
invalid_request400Der Rumpf entspricht nicht dem Vertrag. Fehler im Frontend.
unknown_form404Die Formular-ID steht nicht in der Allowlist des Backends.
validation_failed422Formal richtig, einzelne Felder nicht. Trägt fieldErrors.
security_check_failed403Honeypot gefüllt, Captcha ungültig oder Origin nicht erlaubt.
payload_too_large413Der Rumpf überschreitet das Größenlimit.
rate_limited429Zu viele Anfragen aus derselben Quelle.
internal_error500Unerwarteter Fehler. Nach außen bewusst ohne Einzelheiten.

Die Trennung von 400 und 422 ist die wichtigste: 400 heißt, der Absender kann nichts tun; 422 heißt, er kann genau das korrigieren, was fieldErrors nennt.

Zwei weitere Codes entstehen ausschließlich im Browser und dürfen von keinem Server gesendet werden: network_error (die Anfrage kam nicht zustande) und unexpected_response (es kam eine Antwort, aber keine vertragsgemäße).

Schutz gegen Missbrauch ​

Honeypot. Ein Feld namens website, aus dem Layout genommen und aus dem Sichtfeld geschoben — ausdrücklich kein display: none, denn das ist die erste Eigenschaft, auf die ein ausfüllendes Skript prüft. Für Screenreader ist es über aria-hidden am Container weg. Der Client sendet den gefüllten Zustand mit, statt selbst abzulehnen: Die Entscheidung gehört ins Backend, sonst stünde die Prüfung im ausgelieferten Bündel zum Nachlesen.

Turnstile. Mit captcha: 'turnstile' und PUBLIC_TURNSTILE_SITE_KEY. Das Skript lädt erst bei der ersten Berührung des Formulars — wer eine Seite nur liest, löst keine Verbindung zu Cloudflare aus. Der Secret Key liegt ausschließlich im Backend.

Rate Limiting und Origin-Prüfung sind Sache des Backends. Beim PHP-Handler stehen sie in Standalone-PHP, beim Joomla-Provider im Gateway.

Kampagnenzuordnung ​

Damit eine Anfrage der Anzeige zugeordnet werden kann, die sie ausgelöst hat, gehen utm_*-Parameter sowie gclid, gbraid und wbraid mit. Zwei Dinge sind dabei getrennt:

  • Aus der Adresse lesen passiert immer. Die Parameter stehen in der URL, die der Besucher selbst aufgerufen hat; sie weiterzureichen speichert nichts.
  • Über Seitenwechsel hinweg merken braucht die Einwilligung in die Kategorie marketing. Das ist ein Speicherzugriff zu Marketingzwecken, unabhängig davon, ob es Tracking gibt.

Der Unterschied ist praktisch bedeutsam: Wer über eine Anzeige auf die Startseite kommt und erst danach zum Formular klickt, hat dort keine Parameter mehr. Deshalb läuft die Erfassung in BaseLayout und damit auf jeder Seite — nicht erst auf der mit dem Formular. Gespeichert wird im sessionStorage, nach First Touch, und ein Widerruf entfernt den Wert wieder.

Weil die Zuordnung damit ein Verbraucher der Kategorie marketing ist, erscheint sie im Einstellungsdialog — auch in einem Projekt ganz ohne Tag Manager. Der Bannertext behauptet dort dann nichts über Google; siehe Consent.

Ohne JavaScript ​

Das Formular ist nicht absendbar: Der Endpunkt erwartet JSON, ein gewöhnlicher Formular-POST käme url-encoded an. <Form /> setzt deshalb kein action und zeigt stattdessen einen <noscript>-Hinweis mit der E-Mail-Adresse aus company.email — oder, falls die fehlt, mit dem Verweis aufs Impressum.

Bewusst nicht enthalten ​

  • Kein Formularbuilder. Kein Renderer, der Felder aus einer Datenstruktur erzeugt. Das erste Kundenprojekt mit einem zweispaltigen Formular brächte ihn an die Grenze.
  • Kein Mini-CRM. Der Starter verwaltet keine Anfragen. Wer Speicherung, Status und Backend-Verwaltung braucht, nimmt den Joomla-Provider.
  • Keine Vue-Insel. Vue ist nicht vorinstalliert. Für ein mehrstufiges Formular oder bedingte Felder ist eine Insel gerechtfertigt — siehe Integrationen.

Was noch kommt ​

  • Joomla — die Companion-Erweiterung com_kickastroforms in einem eigenen Repository. Die Frontend-Seite ist vollständig; die Gegenstelle fehlt noch (Details)
  • Same-Origin-Gateway — der PHP-Endpunkt als echter Proxy vor ein Joomla-Backend; in der Architektur nicht verbaut, aber noch nicht umgesetzt — später