Erscheinungsbild
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.
| Provider | Wofür |
|---|---|
standalone-php | Der Regelfall: Kontakt, Rückruf, Bewerbung. Zum Handler |
joomla | Speichern, Verwalten, Exportieren. Zum Provider |
none | Kein 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' },
],
},
]);| Feld | Bedeutung |
|---|---|
name | lowerCamelCase. Wird zum JSON-Schlüssel und in PHP zum Array-Schlüssel. |
type | Steuert die Prüfung, nicht das Aussehen. email heißt „muss eine Adresse sein". |
required | Pflichtfeld. Muss im Markup als required erscheinen — ein Test hält das fest. |
maxLength | Nur bei Textfeldern. Ohne Angabe gilt die Grenze des Vertrags (5000). |
options | Bei 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 istrequired. - Ein Feld mit
consent: 'marketing'darf nichtrequiredsein. 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 neuDerselbe 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
| Code | HTTP | Bedeutung |
|---|---|---|
invalid_request | 400 | Der Rumpf entspricht nicht dem Vertrag. Fehler im Frontend. |
unknown_form | 404 | Die Formular-ID steht nicht in der Allowlist des Backends. |
validation_failed | 422 | Formal richtig, einzelne Felder nicht. Trägt fieldErrors. |
security_check_failed | 403 | Honeypot gefüllt, Captcha ungültig oder Origin nicht erlaubt. |
payload_too_large | 413 | Der Rumpf überschreitet das Größenlimit. |
rate_limited | 429 | Zu viele Anfragen aus derselben Quelle. |
internal_error | 500 | Unerwarteter 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_kickastroformsin 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