Erscheinungsbild
0010 — Standalone-PHP-Handler im Starter-Repository
Status: angenommen · Milestone: M7
Kontext
Mit M6 steht der Formularvertrag, der Client und das Kontaktformular. Was fehlt, ist die Seite, die tatsächlich etwas annimmt. Für den Regelfall eines Kicktemp-Projekts — ein Kontaktformular, eine Rückrufbitte, wenige Anfragen am Tag — soll das ohne CMS gehen: ein PHP-Endpunkt neben der statischen Ausgabe.
Damit stellen sich vier Fragen, die sich nicht getrennt beantworten lassen: Wo liegt dieser Handler? Wie viel Fremdcode darf er zur Laufzeit mitbringen? Woher weiß er, welche Felder ein Formular hat? Und wie wird er konfiguriert, ohne dass Zugangsdaten im Repository landen?
Entscheidung
Der Handler liegt in diesem Repository, unter server/forms/. Er wird zusammen mit dist/ ausgeliefert und gehört zu derselben Auslieferung wie die Seite, die auf ihn zeigt.
PHPMailer ist die einzige Laufzeit-Abhängigkeit. PHPUnit und opis/json-schema sind Entwicklungsabhängigkeiten. Zur Laufzeit prüft handgeschriebener PHP-Code den Vertrag; dass er dasselbe tut wie das erzeugte JSON Schema, beweist ein Contract-Test in beide Richtungen.
Die Feld-Allowlist ist ein zweites erzeugtes Artefakt. server/forms/config/forms.json entsteht aus src/content/forms.ts, geschrieben von pnpm forms:contract und bewacht von einem Snapshot-Test — dieselbe Mechanik wie beim Vertrag.
Konfiguriert wird in drei Schichten: Vorgaben im Code, Umgebungsvariablen KT_FORMS_*, zuletzt config/config.local.php. Die Datei gewinnt und liegt nicht im Repository. Ohne Origin-Allowlist verweigert der Handler den Dienst.
Zustand liegt in Dateien, nicht in einer Datenbank: Rate-Limit-Zähler, Kennungen bereits verarbeiteter Vorgänge und das Log.
Die PHP-Tests sind Teil von pnpm verify. Dieselbe Kette, ein Befehl, identisch in der CI.
Begründung
Warum kein eigenes Repository. Der Handler hat keinen eigenen Lebenszyklus: Er wird mit der Website zusammen ausgeliefert, liest deren erzeugte Allowlist und muss zu deren Vertragsversion passen. Drei Dinge, die gemeinsam veröffentlicht werden müssen, in zwei Repositories zu legen, erzeugt genau die Drift, gegen die ADR 0009 antritt. Bei der Joomla-Erweiterung liegt der Fall anders — sie hat einen eigenen Rhythmus und eine ganz andere Abhängigkeit (ADR 0011).
Warum PHPMailer und nicht selbst geschrieben. SMTP mit STARTTLS, Authentifizierung, Kodierung und sauberen Kopfzeilen ist keine halbe Seite Code. Der Fehlermodus einer eigenen Fassung ist zudem der unangenehmste, den es gibt: Nachrichten, die zugestellt zu sein scheinen und im Spam-Ordner liegen.
Warum keine JSON-Schema-Prüfung zur Laufzeit. Sie wäre die naheliegende Antwort auf „eine Quelle für den Vertrag" — und sie zöge eine Bibliothek in jeden einzelnen Request, für eine Prüfung, die sich in wenigen Zeilen ausdrücken lässt. Die Bibliothek steht stattdessen im Test, wo sie langsam sein darf und wo der Vergleich mit dem Original tatsächlich belegt wird: Was das Schema annimmt, muss der Handler annehmen; was es ablehnt, muss er ablehnen. Eine Prüfung nur in eine Richtung bestünde auch ein Handler, der alles ablehnt.
Warum die Allowlist erzeugt wird. Eine zweite, handgepflegte Feldliste hätte einen besonders unangenehmen Fehlermodus. Läuft der Vertrag auseinander, wird jede Übermittlung abgelehnt — das fällt sofort auf. Läuft die Allowlist auseinander, wird die Anfrage angenommen und genau das Feld weggelassen, das jemand eingetippt hat. Die Anfrage kommt an, die Rückrufnummer fehlt.
Warum Dateien statt Datenbank. Auf einem Hosting, das sonst nur statische Dateien und ein PHP-Skript kennt, ist eine Datei je Zähler die Lösung mit den wenigsten beweglichen Teilen — kein Schema, keine Zugangsdaten, keine Migration. Für die Größenordnung, um die es geht, reicht flock vollständig.
Warum ohne Origin-Allowlist gar nichts geht. Diese eine Einstellung hat keinen brauchbaren Standardwert. „Alles erlauben" wäre ein offenes Relais für jede fremde Seite; „nichts erlauben" wäre ein Formular, das schweigend nichts tut. Der Dienst zu verweigern ist die einzige Variante, die beim ersten Versuch auffällt.
Warum die Feldprüfung vor dem Captcha steht. Ein Turnstile-Token ist einmal einlösbar. Wer es für eine Übermittlung verbraucht, die an einem Tippfehler scheitert, sorgt dafür, dass der zweite Versuch desselben Menschen an der Sicherheitsprüfung hängen bleibt — mit einer Meldung, die nichts mit seiner Eingabe zu tun hat.
Warum erst übergeben und dann bestätigt wird. Eine Bestätigung für eine Anfrage, die niemanden erreicht hat, ist der schlimmste Ausgang, den dieser Handler haben kann: Der Absender wartet auf eine Antwort, und niemand weiß, dass er wartet.
Die Zusage endet allerdings dort, wo die Möglichkeiten eines Absenders enden. success heißt: Der Postausgangsserver hat die Nachricht angenommen. Ob sie ankommt, entscheidet sich danach — am MX-Eintrag der Empfängerdomain, an SPF, DKIM und DMARC, am Spam-Filter, an einer Warteschlange, die tagelang erneut zustellt. Nichts davon sieht der Handler zum Zeitpunkt seiner Antwort, und keine Bauart könnte das ändern: Eine synchrone HTTP-Antwort kann nicht auf einen Vorgang warten, der Stunden dauern darf. Deshalb gehört zum Einrichten eines Projekts eine echte Übermittlung mit Blick ins Postfach — und nicht der Schluss vom grünen Deployment auf den Posteingang.
Verworfene Alternativen
Der Handler als eigenes Repository. Siehe oben: drei Dinge, ein Veröffentlichungszeitpunkt.
mail() als Rückfall, wenn kein SMTP konfiguriert ist. Bequem und in der Wirkung fatal: Ohne authentifizierten Transport landet die Benachrichtigung mit einiger Wahrscheinlichkeit im Spam-Ordner. Ein sichtbarer Fehler ist besser als eine unsichtbar verlorene Anfrage.
Symfony Mailer statt PHPMailer. Mehr Können, mehr Abhängigkeiten, mehr Update-Fläche in zwanzig Kundenprojekten. Für einen Endpunkt, der Textnachrichten an ein Postfach schickt, ist das kein Zugewinn.
Den Honeypot still mit einer Erfolgsmeldung beantworten. Der übliche Kniff gegen Bots — und er verwirft die Anfrage eines Menschen, dessen Browser das Feld automatisch ausgefüllt hat, ohne dass es je jemand erfährt. Der Handler antwortet stattdessen mit security_check_failed; der Grund steht im Log und nicht in der Antwort.
Anfragen zusätzlich speichern. Wäre schnell gebaut und wäre der erste Schritt zu einem Mini-CRM — mit personenbezogenen Daten ohne Oberfläche, ohne Berechtigungen und ohne Aufbewahrungsfrist. Wer Speicherung braucht, nimmt den Joomla-Provider.
Konsequenzen
- PHP 8.3 und Composer werden zur lokalen Voraussetzung.
pnpm verifyruft PHPUnit auf; die CI richtet PHP übershivammathur/setup-phpein. server/forms/vendor/liegt nicht im Repository. Das Deployment musscomposer install --no-devausführen. Das übernimmt seit M8 der Deployment-Workflow (ADR 0014).- Die
.htaccessim Handler-Verzeichnis ist Teil der Sicherheit, nicht Beiwerk: Ohne sie sindconfig/undvar/über HTTP lesbar, sobald der Handler unter dem Dokumentenstamm liegt. - Ein
acceptedim Log belegt keinen Posteingang. Beim Einrichten eines Projekts gehört eine echte Übermittlung dazu, mit Blick ins Postfach. Die häufigsten Ursachen für „alles grün und trotzdem nichts angekommen" stehen bei Standalone-PHP. - Was in der E-Mail steht, formuliert der Betrieb:
labelsbeschriftet Feldnamen,optionLabelsdie Werte einer Auswahlliste. Beides gehört inconfig.local.phpund nicht in die Allowlist — die sagt, was angenommen wird, nicht was ein Mensch liest. pnpm forms:contractschreibt jetzt zwei Artefakte. Beide Diffs gehören in denselben Commit wie die Änderung, die sie ausgelöst hat.- Playwright hat ein viertes Projekt
forms-live. Es fährt die Fixture „full" hinter dem eingebauten PHP-Server und prüft den echten Weg — die gemockten Szenarien informs.spec.tsbleiben, weil sich ein Rate Limit oder eine abgeschnittene Antwort dort leichter herstellen lässt. - Der Handler kennt einen Transport
file, der die Nachricht als.emlablegt statt sie zu versenden. Er ist für Entwicklung und Testkette gedacht; auf einem Produktionssystem ist er ein Konfigurationsfehler und wird bei jeder Übermittlung als Warnung protokolliert.