Skip to content

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-curl wird 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/forms

Lokal ü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 kann

Drei Punkte entscheiden über die Sicherheit dieser Aufteilung:

  1. Die .htaccess muss greifen. Sie sperrt das Verzeichnis und gibt nur submit.php frei. Ist AllowOverride abgeschaltet, gehört dieselbe Regel in die Serverkonfiguration — sonst sind config/ und var/ über HTTP lesbar. Für nginx:

    nginx
    location ^~ /api/forms/ { return 404; }
    location = /api/forms/submit.php { include fastcgi_params; fastcgi_pass php; }
  2. Das Ablageverzeichnis gehört möglichst außerhalb des Dokumentenstamms. Wo das geht, per KT_FORMS_STORAGE_DIR; wo nicht, sperrt eine zweite .htaccess es ab, die der Handler beim ersten Start selbst anlegt.

  3. config.local.php wird 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 ​

VariableVorgabeBedeutung
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_SUBJECTNeue Anfrage über …Betreff; {form} wird durch die Formular-ID ersetzt
KT_FORMS_MAIL_TRANSPORTsmtpsmtp oder file (nur Entwicklung)
KT_FORMS_SMTP_HOST—Erforderlich bei smtp
KT_FORMS_SMTP_PORT587
KT_FORMS_SMTP_ENCRYPTIONtlstls (STARTTLS), ssl (implizit) oder none
KT_FORMS_SMTP_USERNAME—Ohne Benutzer wird nicht authentifiziert
KT_FORMS_SMTP_PASSWORD—
KT_FORMS_SMTP_TIMEOUT10Sekunden
KT_FORMS_CAPTCHAnonenone oder turnstile
KT_FORMS_TURNSTILE_SECRET—Erforderlich bei turnstile
KT_FORMS_CAPTCHA_TIMEOUT5Sekunden bis zum Abbruch der Prüfung
KT_FORMS_RATE_LIMIT_IP5Versuche je Adresse; 0 schaltet die Regel ab
KT_FORMS_RATE_LIMIT_IP_WINDOW600Sekunden
KT_FORMS_RATE_LIMIT_GLOBAL120Versuche insgesamt
KT_FORMS_RATE_LIMIT_GLOBAL_WINDOW3600Sekunden
KT_FORMS_IDEMPOTENCY_TTL900Wie lange eine Kennung als derselbe Vorgang gilt
KT_FORMS_MAX_BODY_BYTES65536Größenlimit des Rumpfes
KT_FORMS_STORAGE_DIRserver/forms/varZähler, Kennungen, Log
KT_FORMS_TRUSTED_PROXIES—Adressen, deren X-Forwarded-For gilt
KT_FORMS_LOG_RETENTION_DAYS30Aufbewahrungsfrist des Logs
KT_FORMS_WEBHOOK—https-Adresse für die Weitergabe
KT_FORMS_CONFIGconfig/config.local.phpAnderer 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:

  1. Methode und Content-Type. Nur POST mit application/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.
  2. Größenlimit → payload_too_large.
  3. Origin-Allowlist → security_check_failed. Fehlt Origin, gilt ersatzweise Referer.
  4. Vertragsprüfung → invalid_request. Struktur, Grenzen, Zeitstempel, Einwilligung.
  5. Formular und Fassung → unknown_form. Eine veraltete Seite aus dem Browser-Cache ist kein Feldfehler; „neu laden" hilft, eine Liste von Feldern nicht.
  6. Honeypot → security_check_failed.
  7. Rate Limit je Adresse und insgesamt → rate_limited. Auch der abgewiesene Versuch zählt.
  8. Doppelsubmission: Ist dieselbe Kennung bereits verarbeitet, kommt die alte Antwort zurück — ohne zweite E-Mail.
  9. Feldprüfung gegen die Allowlist → validation_failed mit fieldErrors.
  10. 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.
  11. Ü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 LogUrsache
accepted, aber kein PosteingangEmpfängerdomain ohne MX-Eintrag, oder Nachricht in der Warteschlange
Could not authenticateZugangsdaten in config.local.php falsch
Recipient address rejectedTippfehler in der Empfängeradresse
rate_limited beim eigenen TestenFü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 verify

Drei Ebenen sichern den Handler ab:

  • Contract-Tests prüfen gegen dieselbe Datei, die das Frontend erzeugt: Was contract/form-submission.schema.json annimmt, 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.ts fä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 ​

SymptomUrsache
Jede Übermittlung endet in internal_errorKonfiguration unvollständig — der Grund steht im Server-Log
security_check_failed bei jedem VersuchKT_FORMS_ORIGINS passt nicht zur tatsächlichen Adresse
unknown_form nach einer Feldänderungpnpm forms:contract vergessen oder config/forms.json alt
invalid_request mit einem neuen FeldFeld 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 ErfolgKT_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.