Erscheinungsbild
Standalone-PHP
Der Handler unter server/forms/ nimmt Formularübermittlungen an, prüft sie und verschickt eine Benachrichtigung. Er wird zusammen mit dist/ ausgeliefert und ist über forms.endpoint erreichbar — voreingestellt /api/forms/submit.php.
ts
forms: {
provider: 'standalone-php',
endpoint: '/api/forms/submit.php', // same-origin, muss mit / beginnen
captcha: 'turnstile', // optional
}Er ist kein CMS und kein Mini-CRM. Er nimmt an, prüft, schickt eine E-Mail, ruft auf Wunsch einen Webhook und schreibt eine datensparsame Zeile ins Log. Anfragen verwalten, Status pflegen, im Backend durchsuchen — dafür gibt es den Joomla-Provider.
Aufbau
text
server/forms/
├── submit.php einziger Einstiegspunkt
├── .htaccess sperrt alles außer submit.php
├── composer.json / composer.lock PHPMailer als einzige Laufzeit-Abhängigkeit
├── config/
│ ├── forms.json erzeugte Feld-Allowlist (nicht von Hand ändern)
│ ├── config.local.php Betriebskonfiguration samt Geheimnissen (nicht im Repo)
│ └── config.local.php.example Vorlage dafür
├── src/ die Klassen, die entscheiden
├── tests/ PHPUnit, inklusive Contract-Tests
└── var/ Rate Limits, Kennungen, Log (nicht im Repo)config/forms.json entsteht aus src/content/forms.ts — dieselbe Quelle, aus der auch der Browser prüft. pnpm forms:contract schreibt die Artefakte neu, ein Snapshot-Test bricht ab, sobald eines veraltet ist. Dieselbe Liste liegt als contract/forms.allowlist.json noch einmal im Repository: Sie entsteht auch in Projekten ohne diesen Handler und ist das, was ein Backend in einem anderen Repository lesen kann (ADR 0018).
Ein Feld ergänzen heißt: beide Dateien
Wer src/content/forms.ts ändert und das Erzeugen vergisst, bekommt ein Formular, dessen neues Feld der Handler nicht kennt — und der lehnt die ganze Übermittlung ab. Der Test in der Kette verhindert das; das Erzeugen von Hand nachzuholen ist trotzdem ein Handgriff wert.
Voraussetzungen
- PHP 8.3 oder neuer, mit
ext-json.ext-curlwird benutzt, wenn vorhanden — sonst greift ein Stream-Kontext. - Composer für die Installation der Abhängigkeit (PHPMailer).
- Ein SMTP-Postfach für den Versand.
mail()ist ausdrücklich kein Rückfall.
sh
composer install --no-dev --working-dir=server/formsLokal übernimmt das pnpm run php:install — inklusive der Entwicklungsabhängigkeiten, die die Tests brauchen.
Ausliefern
Das Zielsystem bekommt zwei Dinge: den Inhalt von dist/ und das Verzeichnis des Handlers. Damit der Endpunkt unter /api/forms/submit.php liegt, landet der Handler unter <Dokumentenstamm>/api/forms/:
text
httpdocs/
├── index.html … aus dist/
└── api/forms/
├── submit.php
├── .htaccess
├── src/ vendor/ config/
└── var/ nur wenn es nicht daneben liegen kannDrei Punkte entscheiden über die Sicherheit dieser Aufteilung:
Die
.htaccessmuss greifen. Sie sperrt das Verzeichnis und gibt nursubmit.phpfrei. IstAllowOverrideabgeschaltet, gehört dieselbe Regel in die Serverkonfiguration — sonst sindconfig/undvar/über HTTP lesbar. Für nginx:nginxlocation ^~ /api/forms/ { return 404; } location = /api/forms/submit.php { include fastcgi_params; fastcgi_pass php; }Das Ablageverzeichnis gehört möglichst außerhalb des Dokumentenstamms. Wo das geht, per
KT_FORMS_STORAGE_DIR; wo nicht, sperrt eine zweite.htaccesses ab, die der Handler beim ersten Start selbst anlegt.config.local.phpwird nicht mit dem Build ausgeliefert. Sie liegt auf dem Server und bleibt dort — sie enthält das SMTP-Passwort.
Den Workflow dazu — Build in GitHub Actions, Übertragung per rsync, Rollback — beschreibt Deployment nach Mittwald. Er nimmt config/config.local.php und var/ vom Abgleich aus und überträgt den Handler in einem eigenen Aufruf; welches Verzeichnis das ist, leitet er aus forms.endpoint ab.
Konfigurieren
Drei Quellen, in dieser Reihenfolge: Voreinstellungen, Umgebungsvariablen, config.local.php. Die Datei gewinnt, weil sie das ist, was jemand auf genau diesem Server hingeschrieben hat.
Ein Projekt kommt mit einer der beiden Formen aus. Empfänger je Formular, Beschriftungen für die E-Mail und ein eigener Bestätigungstext lassen sich allerdings nur in der Datei ausdrücken.
Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
KT_FORMS_ORIGINS | — | Erforderlich. Erlaubte Herkunft, kommagetrennt |
KT_FORMS_RECIPIENTS | — | Empfänger für alle Formulare, kommagetrennt |
KT_FORMS_MAIL_FROM | — | Erforderlich. Absenderadresse |
KT_FORMS_MAIL_FROM_NAME | — | Anzeigename des Absenders |
KT_FORMS_SUBJECT | Neue Anfrage über … | Betreff; {form} wird durch die Formular-ID ersetzt |
KT_FORMS_MAIL_TRANSPORT | smtp | smtp oder file (nur Entwicklung) |
KT_FORMS_SMTP_HOST | — | Erforderlich bei smtp |
KT_FORMS_SMTP_PORT | 587 | |
KT_FORMS_SMTP_ENCRYPTION | tls | tls (STARTTLS), ssl (implizit) oder none |
KT_FORMS_SMTP_USERNAME | — | Ohne Benutzer wird nicht authentifiziert |
KT_FORMS_SMTP_PASSWORD | — | |
KT_FORMS_SMTP_TIMEOUT | 10 | Sekunden |
KT_FORMS_CAPTCHA | none | none oder turnstile |
KT_FORMS_TURNSTILE_SECRET | — | Erforderlich bei turnstile |
KT_FORMS_CAPTCHA_TIMEOUT | 5 | Sekunden bis zum Abbruch der Prüfung |
KT_FORMS_RATE_LIMIT_IP | 5 | Versuche je Adresse; 0 schaltet die Regel ab |
KT_FORMS_RATE_LIMIT_IP_WINDOW | 600 | Sekunden |
KT_FORMS_RATE_LIMIT_GLOBAL | 120 | Versuche insgesamt |
KT_FORMS_RATE_LIMIT_GLOBAL_WINDOW | 3600 | Sekunden |
KT_FORMS_IDEMPOTENCY_TTL | 900 | Wie lange eine Kennung als derselbe Vorgang gilt |
KT_FORMS_MAX_BODY_BYTES | 65536 | Größenlimit des Rumpfes |
KT_FORMS_STORAGE_DIR | server/forms/var | Zähler, Kennungen, Log |
KT_FORMS_TRUSTED_PROXIES | — | Adressen, deren X-Forwarded-For gilt |
KT_FORMS_LOG_RETENTION_DAYS | 30 | Aufbewahrungsfrist des Logs |
KT_FORMS_WEBHOOK | — | https-Adresse für die Weitergabe |
KT_FORMS_CONFIG | config/config.local.php | Anderer Pfad zur Konfigurationsdatei |
config.local.php
config/config.local.php.example kopieren und anpassen. Weggelassene Schlüssel behalten ihre Vorgabe:
php
return [
'origins' => ['https://www.example.de'],
'mail' => [
'host' => 'smtp.example.de',
'username' => 'formular@example.de',
'password' => '…',
'from' => 'formular@example.de',
'fromName' => 'Website',
],
'forms' => [
'kontakt' => [
'recipients' => ['kontakt@example.de'],
'subject' => 'Kontaktanfrage von der Website',
'successMessage' => 'Vielen Dank. Wir melden uns innerhalb eines Werktages.',
'labels' => ['name' => 'Name', 'nachricht' => 'Nachricht'],
'optionLabels' => ['anliegen' => ['angebot' => 'Angebot']],
],
],
];labels beschriftet die Feldnamen, optionLabels die Werte einer Auswahlliste. Beides steht hier und nicht in src/content/forms.ts: Die Allowlist sagt, was angenommen wird, das Markup der Seite sagt, was ein Mensch liest — und in das Markup sieht der Handler nicht (ADR 0009). Ohne Eintrag steht in der E-Mail der übermittelte Wert, also angebot statt „Angebot".
replyToField braucht man selten: Ohne Angabe nimmt der Handler das erste E-Mail-Feld des Formulars als Antwortadresse. Nötig ist der Eintrag nur, wenn ein Formular mehrere E-Mail-Felder hat und nicht das erste gemeint ist.
Ohne Origin-Allowlist läuft nichts
origins hat keinen brauchbaren Standardwert: „alles erlauben" wäre ein offenes Relais. Fehlt die Angabe, antwortet der Handler mit internal_error und schreibt den Grund ins Server-Log — laut und beim ersten Versuch, statt still und dauerhaft.
Der Absender gehört zur eigenen Domain
Wer die Adresse des Anfragenden als From setzt, lässt die Nachricht an SPF und DMARC scheitern; sie kommt gar nicht erst an. Sie steht deshalb in Reply-To — Antworten aus dem Postfach gehen trotzdem an die richtige Stelle.
Der Weg einer Übermittlung
Die Reihenfolge ist nicht beliebig:
- Methode und Content-Type. Nur
POSTmitapplication/json. Ein gewöhnliches<form>auf einer fremden Seite kann diesen Content-Type ohne Preflight gar nicht senden — dieselbe Wirkung wie ein CSRF-Token, ohne eine Sitzung führen zu müssen. - Größenlimit →
payload_too_large. - Origin-Allowlist →
security_check_failed. FehltOrigin, gilt ersatzweiseReferer. - Vertragsprüfung →
invalid_request. Struktur, Grenzen, Zeitstempel, Einwilligung. - Formular und Fassung →
unknown_form. Eine veraltete Seite aus dem Browser-Cache ist kein Feldfehler; „neu laden" hilft, eine Liste von Feldern nicht. - Honeypot →
security_check_failed. - Rate Limit je Adresse und insgesamt →
rate_limited. Auch der abgewiesene Versuch zählt. - Doppelsubmission: Ist dieselbe Kennung bereits verarbeitet, kommt die alte Antwort zurück — ohne zweite E-Mail.
- Feldprüfung gegen die Allowlist →
validation_failedmitfieldErrors. - Captcha — erst hier, denn ein Turnstile-Token ist einmal einlösbar. Wer es für eine Übermittlung verbraucht, die an einem Tippfehler scheitert, lässt den zweiten Versuch desselben Menschen an der Sicherheitsprüfung scheitern.
- Übergabe an den Postausgangsserver. Erst wenn der die Benachrichtigung angenommen hat, gilt die Anfrage als angenommen. Scheitert schon das, antwortet der Handler mit
internal_error— nie mit einer Bestätigung für eine Nachricht, die er nicht loswerden konnte.
Die Fehlercodes und ihre HTTP-Status stehen im Vertrag. Eine Ausnahme gibt es: 405 auf eine andere Methode als POST oder OPTIONS. Das ist eine Aussage über die Methode und nicht über das Formular.
Übergabe ist nicht Zustellung
Schritt 11 endet, wo die Möglichkeiten des Handlers enden. success: true heißt: Der Postausgangsserver hat die Nachricht angenommen. Was danach kommt, sieht er nicht mehr — MX-Eintrag der Empfängerdomain, SPF, DKIM, DMARC, Spam-Filter, Warteschlange. Eine Nachricht kann angenommen werden, tagelang in einer Warteschlange liegen und nie ankommen, ohne dass der Handler oder das Log davon erfahren.
Beim Einrichten eines Projekts gilt deshalb: eine echte Übermittlung abschicken und im Postfach nachsehen. Ein grünes Deployment, ein accepted im Log und ein success in der Antwort belegen zusammen nur, dass der Weg bis zum Postausgangsserver stimmt.
Die häufigsten Ursachen, wenn nichts ankommt und trotzdem alles grün ist:
| Symptom im Log | Ursache |
|---|---|
accepted, aber kein Posteingang | Empfängerdomain ohne MX-Eintrag, oder Nachricht in der Warteschlange |
Could not authenticate | Zugangsdaten in config.local.php falsch |
Recipient address rejected | Tippfehler in der Empfängeradresse |
rate_limited beim eigenen Testen | Fünf Versuche in zehn Minuten erreicht — Zähler in var/ löschen |
| ::: |
Schutz gegen Missbrauch
Origin-Prüfung. Die einzige Angabe, die ein Browser zuverlässig setzt und eine fremde Seite nicht fälschen kann. Gegen ein Skript ohne Browser hilft sie nicht — deshalb steht der Rest daneben.
Rate Limiting. Gleitendes Zeitfenster, eine Datei je Zähler. Zwei Grenzen greifen ineinander: eine je Adresse gegen den Wiederholungstäter, eine für alle zusammen gegen die Welle aus vielen Adressen. Gespeichert wird unter einem Hash aus Adresse und einem Wert, den nur diese Installation kennt — im Ablageverzeichnis steht dadurch keine IP-Adresse.
Honeypot. Das Feld website aus dem Markup. Der Browser sendet den Wert mit; die Entscheidung trifft ausschließlich der Handler.
Turnstile. Mit KT_FORMS_CAPTCHA=turnstile und dem Secret. Ist Cloudflare nicht erreichbar, wird abgelehnt und nicht durchgelassen: Sonst hätte jeder, der die Verbindung stört, das Captcha abgeschaltet. Die Antwort ist dann internal_error, denn mit dem Absender hat es nichts zu tun.
Doppelsubmissions. Der Browser erzeugt je Vorgang eine Kennung und behält sie über einen erneuten Versuch hinweg. Der Handler merkt sich das Ergebnis für die konfigurierte Frist. Nach einem gescheiterten Versand wird die Kennung wieder freigegeben — sonst bekäme der zweite Klick eine Bestätigung für nichts.
Log und Aufbewahrung
Eine Zeile JSON je Entscheidung, eine Datei je Tag, gelöscht nach logRetentionDays. Enthalten sind Zeitpunkt, Formular, Ausgang, Grund und die Kennung — keine Feldinhalte und keine IP-Adresse, sondern nur deren Hash. Ein Log mit den Nachrichten aus dem Kontaktformular wäre eine zweite, unbeaufsichtigte Kopie personenbezogener Daten.
Fehler gehen zusätzlich ins Server-Log: Ist das eigene Ablageverzeichnis nicht beschreibbar, ist die Zeile oben nirgends gelandet.
Weitergeben per Webhook
webhook je Formular, ausschließlich https. Übertragen wird die Übermittlung in derselben Form, in der sie ankam, ergänzt um die Kennung. Voreingestellt ist der Webhook nicht Bedingung für den Erfolg: Die Verfügbarkeit eines fremden Systems soll nicht darüber bestimmen, ob jemand Kontakt aufnehmen kann. Wer das anders will, setzt webhookRequired.
Tests
sh
pnpm run test:php # PHPUnit; Teil von pnpm verifyDrei Ebenen sichern den Handler ab:
- Contract-Tests prüfen gegen dieselbe Datei, die das Frontend erzeugt: Was
contract/form-submission.schema.jsonannimmt, muss der Handler annehmen, und was es ablehnt, muss er ablehnen. Dazu die Fehlercodes samt HTTP-Status. - Handler-Tests fahren jeden Ausgang durch — Erfolg, Feldfehler, Honeypot, Rate Limit, Doppelsubmission, gescheiterter Versand — und prüfen, ob dabei tatsächlich eine E-Mail entstanden ist.
- Der Durchstich in
tests/e2e/forms-live.spec.tsfährt die gebaute Fixture hinter einem PHP-Server mit dem echten Handler. Nichts wird abgefangen; die erzeugte Nachricht wird gelesen.
Die Handler-Tests benutzen eine eigene Allowlist unter server/forms/tests/fixtures/forms.json. Ein Kundenprojekt, das sein Kontaktformular umbaut, färbt sie dadurch nicht rot.
Wenn etwas nicht funktioniert
| Symptom | Ursache |
|---|---|
Jede Übermittlung endet in internal_error | Konfiguration unvollständig — der Grund steht im Server-Log |
security_check_failed bei jedem Versuch | KT_FORMS_ORIGINS passt nicht zur tatsächlichen Adresse |
unknown_form nach einer Feldänderung | pnpm forms:contract vergessen oder config/forms.json alt |
invalid_request mit einem neuen Feld | Feld im Markup, aber nicht in src/content/forms.ts |
| Antwort ist HTML statt JSON | .htaccess greift nicht; der Webserver liefert eine Fehlerseite |
| Keine E-Mail, aber Erfolg | KT_FORMS_MAIL_TRANSPORT steht auf file — siehe Log |
Bewusst nicht enthalten
- Kein Speichern der Anfragen. Der Handler legt keine Datenbank an. Wer Anfragen verwalten will, nimmt den Joomla-Provider.
- Kein Formularbuilder. Die Felder stehen im Markup der Seite, die Allowlist beschreibt sie nur.
- Keine Oberfläche. Es gibt nichts anzumelden und nichts zu bedienen.
- Kein Proxy. Der Handler verarbeitet selbst. Die Betriebsart, in der er Anfragen an ein Joomla-Backend weiterreicht, ist beschrieben, aber nicht umgesetzt — siehe Joomla.