Contact Form 7, WPForms, Gravity Forms – alle funktionieren, doch für ein einfaches Formular mit „Name, E-Mail, Nachricht“ holen Sie sich oft Dutzende PHP-Dateien, eigene Skripte und Styles auf jeder Unterseite sowie ein weiteres Plugin ins Haus, das Sie aktualisieren müssen. Dabei bringt WordPress alles mit, was ein solches Formular braucht: Shortcodes, die sichere POST-Verarbeitung über admin-post.php, Nonces, Sanitizing-Funktionen und wp_mail(). Ich zeige ein vollständiges Formular in einer einzigen Datei, mit Spamschutz und sauberem Post/Redirect/Get-Ablauf.
Was das Formular kann
den Shortcode
[wl_contact], den Sie auf jeder beliebigen Seite einfügen können,die Verarbeitung des Absendens über
admin-post.php(ohne eigene Endpunkte und ohne AJAX),Nonce-Schutz vor CSRF,
Bereinigung der Eingaben und Escaping der Ausgaben,
drei Anti-Spam-Schichten: Honeypot, Time-Trap und ein Limit für Nachrichten pro IP-Adresse,
Erfolgs- und Fehlermeldungen nach der Weiterleitung (PRG-Muster).
Wohin mit dem Code
Sie haben drei Möglichkeiten:
functions.phpdes Themes – am schnellsten, aber das Formular verschwindet beim Theme-Wechsel, und ein Update des Eltern-Themes überschreibt Ihre Änderungen.Ein eigenes Plugin – eine Datei in
wp-content/plugins/, die Sie im Backend aktivieren.Ein Must-Use-Plugin – eine Datei in
wp-content/mu-plugins/, die immer geladen wird und sich nicht versehentlich deaktivieren lässt.
Ich empfehle die dritte Variante: Sie bleibt „ohne Plugins“ im Sinne externer Abhängigkeiten, und die Funktionalität hängt nicht am Theme. Legen Sie die Datei wp-content/mu-plugins/wl-contact.php an. Der gesamte Code unten ist eine einzige Datei, aufgeteilt in drei Abschnitte.
Schritt 1: Konstanten und Registrierung der Hooks
<?php
/**
* Plugin Name: Wirelab – Kontaktformular
* Description: Shortcode [wl_contact] mit Verarbeitung über admin-post.php.
*/
if (!defined('ABSPATH')) {
exit;
}
const WL_CONTACT_ACTION = 'wl_contact';
const WL_CONTACT_MIN_SECS = 3; // Time-Trap: Mindestdauer zum Ausfüllen in Sekunden
const WL_CONTACT_LIMIT = 3; // max. Nachrichten pro IP…
const WL_CONTACT_WINDOW = 10 * MINUTE_IN_SECONDS; // …in diesem Zeitfenster
add_shortcode('wl_contact', 'wl_contact_render');
add_action('admin_post_nopriv_' . WL_CONTACT_ACTION, 'wl_contact_handle');
add_action('admin_post_' . WL_CONTACT_ACTION, 'wl_contact_handle');
Der Hook admin_post_nopriv_{Aktion} bedient nicht angemeldete Besucher, admin_post_{Aktion} angemeldete Benutzer. Sie brauchen beide, sonst funktioniert das Formular nicht, wenn Sie es als Administrator testen (oder umgekehrt – nicht für Gäste). Der Name der Aktion stammt aus dem versteckten Feld action im Formular.
Schritt 2: Das Formular rendern
function wl_contact_render(): string
{
$messages = [
'ok' => ['success', 'Vielen Dank! Ihre Nachricht wurde gesendet.'],
'invalid' => ['error', 'Bitte füllen Sie alle Felder korrekt aus (Nachricht: mind. 10 Zeichen).'],
'spam' => ['error', 'Das Formular konnte nicht gesendet werden. Laden Sie die Seite neu und versuchen Sie es erneut.'],
'limit' => ['error', 'Es wurden zu viele Nachrichten gesendet. Versuchen Sie es in einigen Minuten erneut.'],
'mail' => ['error', 'Der Server konnte die Nachricht nicht versenden. Schreiben Sie uns bitte direkt per E-Mail.'],
];
$status = isset($_GET['kontakt']) ? sanitize_key(wp_unslash($_GET['kontakt'])) : '';
$ts = (string) time();
ob_start();
?>
<div id="kontakt" class="wl-contact">
<?php if (isset($messages[$status])) : [$type, $text] = $messages[$status]; ?>
<div class="wl-contact__notice wl-contact__notice--<?php echo esc_attr($type); ?>"
role="<?php echo $type === 'error' ? 'alert' : 'status'; ?>">
<?php echo esc_html($text); ?>
</div>
<?php endif; ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="<?php echo esc_attr(WL_CONTACT_ACTION); ?>">
<input type="hidden" name="wl_ts" value="<?php echo esc_attr($ts . '|' . wp_hash($ts)); ?>">
<?php wp_nonce_field(WL_CONTACT_ACTION, 'wl_nonce'); ?>
<p>
<label for="wl-name">Name</label>
<input id="wl-name" name="wl_name" type="text" required maxlength="80" autocomplete="name">
</p>
<p>
<label for="wl-email">E-Mail</label>
<input id="wl-email" name="wl_email" type="email" required maxlength="254" autocomplete="email">
</p>
<p>
<label for="wl-message">Nachricht</label>
<textarea id="wl-message" name="wl_message" rows="6" required minlength="10" maxlength="5000"></textarea>
</p>
<p class="wl-contact__hp" aria-hidden="true">
<label for="wl-website">Lassen Sie dieses Feld leer</label>
<input id="wl-website" name="wl_website" type="text" tabindex="-1" autocomplete="off">
</p>
<p><button type="submit">Nachricht senden</button></p>
</form>
</div>
<?php
return (string) ob_get_clean();
}
Worauf Sie achten sollten:
Escaping bei der Ausgabe. Jeder Wert, der ins HTML gelangt, läuft durch
esc_html(),esc_attr()oderesc_url(). Der Status aus der URL wird nie direkt ausgegeben – er dient nur als Schlüssel für ein Array fertiger Meldungen, ein präparierter Link wie?kontakt=<script>bewirkt also nichts.wp_nonce_field()erzeugt ein verstecktes Feld mit dem Nonce sowie das Feld_wp_http_referer, anhand dessen der Handler weiß, wohin er zurückleiten soll.Signierter Zeitstempel. Das Feld
wl_tsenthält den Zeitpunkt der Formularerzeugung und dessen HMAC auswp_hash(). Ein Bot kann die Signatur nicht fälschen, ohne die Salts aus derwp-config.phpzu kennen.Der Honeypot ist ein gewöhnliches Textfeld, das vor Menschen verborgen ist.
aria-hidden="true"undtabindex="-1"sorgen dafür, dass es auch Screenreader- und Tastaturnutzer nicht erreicht.role="alert"undrole="status"bewirken, dass der Screenreader die Meldung nach dem Neuladen vorliest.Ein Shortcode muss das HTML zurückgeben, nicht ausgeben. Daher
ob_start()undob_get_clean().
Verstecken Sie den Honeypot per CSS im Theme. Verwenden Sie kein display: none im style-Attribut – manche Bots prüfen das:
.wl-contact__hp {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
Schritt 3: Das Absenden verarbeiten
function wl_contact_redirect(string $status): void
{
$back = remove_query_arg('kontakt', wp_get_referer() ?: home_url('/'));
wp_safe_redirect(add_query_arg('kontakt', $status, $back) . '#kontakt', 303);
exit;
}
function wl_contact_handle(): void
{
// 1. Nonce: stammt das Formular von unserer Seite?
$nonce = isset($_POST['wl_nonce']) ? sanitize_text_field(wp_unslash($_POST['wl_nonce'])) : '';
if (!wp_verify_nonce($nonce, WL_CONTACT_ACTION)) {
wl_contact_redirect('spam');
}
// 2. Honeypot: ein Mensch sieht dieses Feld nicht
if (!empty($_POST['wl_website'])) {
wl_contact_redirect('ok'); // Erfolg vortäuschen – der Bot erhält keinen Hinweis
}
// 3. Time-Trap: signierter Zeitstempel aus dem Formular
$raw = isset($_POST['wl_ts']) ? sanitize_text_field(wp_unslash($_POST['wl_ts'])) : '';
[$ts, $sig] = array_pad(explode('|', $raw, 2), 2, '');
if (!hash_equals(wp_hash($ts), $sig) || time() - (int) $ts < WL_CONTACT_MIN_SECS) {
wl_contact_redirect('spam');
}
// 4. Rate-Limit über Transients (Schlüssel aus dem IP-Hash – die IP wird nicht in der Datenbank gespeichert)
$ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
$key = 'wl_contact_' . md5(wp_hash($ip));
$count = (int) get_transient($key);
if ($count >= WL_CONTACT_LIMIT) {
wl_contact_redirect('limit');
}
// 5. Bereinigung und Validierung
$name = sanitize_text_field(wp_unslash($_POST['wl_name'] ?? ''));
$email = sanitize_email(wp_unslash($_POST['wl_email'] ?? ''));
$message = sanitize_textarea_field(wp_unslash($_POST['wl_message'] ?? ''));
if ($name === '' || mb_strlen($name) > 80 || !is_email($email)
|| mb_strlen($message) < 10 || mb_strlen($message) > 5000) {
wl_contact_redirect('invalid');
}
set_transient($key, $count + 1, WL_CONTACT_WINDOW);
// 6. Versand
$site = wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
$subject = sprintf('[%s] Nachricht von: %s', $site, $name);
$body = "Name: {$name}\nE-Mail: {$email}\n\n{$message}\n";
$headers = ['Content-Type: text/plain; charset=UTF-8', 'Reply-To: ' . $email];
$sent = wp_mail(get_option('admin_email'), $subject, $body, $headers);
wl_contact_redirect($sent ? 'ok' : 'mail');
}
Die Reihenfolge der Prüfungen ist wichtig: Die günstigsten und aussagekräftigsten stehen am Anfang.
Nonce
wp_verify_nonce() bestätigt, dass das Formular innerhalb der letzten Stunden von Ihrer Seite erzeugt wurde (ein WordPress-Nonce lebt 12 bis 24 Stunden). Das schützt vor CSRF und siebt nebenbei die simpelsten Bots aus, die ein POST senden, ohne die Seite abzurufen.
Vorsicht beim Seiten-Cache. Wird die Seite mit dem Formular aus dem Cache ausgeliefert (etwa durch ein Cache-Plugin oder einen Reverse-Proxy), erhalten alle denselben, mit der Zeit abgelaufenen Nonce, und nach einem Tag meldet das Formular einen Fehler. Nehmen Sie die Kontaktseite aus dem Cache aus oder verkürzen Sie die Lebensdauer ihrer Kopie auf weniger als 12 Stunden.
Honeypot
Ist das Feld wl_website nicht leer, leiten Sie mit dem Status „ok“ weiter, senden aber nichts. Der Bot „glaubt“, er sei erfolgreich gewesen, und versucht nicht, den Schutz zu umgehen.
Time-Trap
Ein Mensch braucht mindestens einige Sekunden, um drei Felder auszufüllen. Ein Skript einen Sekundenbruchteil. Der Vergleich über hash_equals() statt === schützt vor Timing-Angriffen. Setzen Sie die Schwelle nicht zu hoch: Passwortmanager und Autovervollständigung können ein Formular sehr schnell ausfüllen. Drei Sekunden sind ein vernünftiger Kompromiss.
Rate-Limit über Transients
Ein Transient ist ein Wert in der Datenbank (oder in Redis/Memcached, falls Sie einen Object-Cache haben) mit Ablaufzeit. Den Schlüssel bilden Sie aus dem Hash der IP-Adresse, sodass die Adresse selbst nicht in die Datenbank gelangt. Zwei Anmerkungen:
set_transient()erneuert bei jedem Schreiben die Ablaufzeit, das Fenster ist also „gleitend“ – das Limit wird 10 Minuten nach der letzten Nachricht zurückgesetzt. Für ein Kontaktformular reicht das völlig aus.Hinter Cloudflare oder einem anderen Reverse-Proxy ist
REMOTE_ADDRdie Adresse des Proxys, und alle Nutzer teilen sich ein Limit. Holen Sie dann die echte IP aus dem vom Proxy gesetzten Header (z. B.CF-Connecting-IP), aber nur, wenn der Server Datenverkehr ausschließlich von diesem Proxy annimmt.
Bereinigung und Validierung
Das sind zwei verschiedene Schritte. Die Bereinigung (Sanitizing) säubert die Daten: sanitize_text_field() entfernt Tags und Zeilenumbrüche, sanitize_textarea_field() tut dasselbe, behält aber Zeilenumbrüche, und sanitize_email() entfernt unzulässige Zeichen. Die Validierung prüft, ob das Ergebnis sinnvoll ist: is_email(), Feldlängen. Beachten Sie wp_unslash() – WordPress fügt den Daten aus $_POST Schrägstriche hinzu, ohne diesen Aufruf würde aus dem Apostroph im Namen O’Brien ein O\'Brien.
Die Nachricht versenden Sie als reinen Text. Selbst wenn also etwas die Bereinigung überstünde, würde der Mailclient es nicht als HTML darstellen.
Versand und Header
Lassen Sie die Absenderadresse auf dem Standardwert (oder setzen Sie eine Adresse in Ihrer Domain) und tragen Sie die E-Mail-Adresse des Nutzers in Reply-To ein. Die Adresse aus dem Formular in From zu setzen, ist der sicherste Weg, im Spamordner zu landen – der Mailserver des Empfängers sieht, dass Ihr Server „im Namen“ einer fremden Domain sendet, was die SPF- und DMARC-Prüfung nicht besteht. Die Header sind gegen Injection sicher, weil sanitize_email() keine Zeilenumbrüche durchlässt.
Post/Redirect/Get
Jeder Pfad endet mit wl_contact_redirect(), also einer 303-Weiterleitung zurück auf die Formularseite. So sendet ein Neuladen der Seite nach dem Absenden die Nachricht nicht ein zweites Mal, und die Meldung ergibt sich aus dem Parameter kontakt in der Adresse. wp_safe_redirect() erlaubt Weiterleitungen nur auf Adressen Ihrer Domain, ein gefälschter Referer macht das Formular also nicht zu einem offenen Redirect.
Zustellbarkeit: warum wp_mail() nicht alles ist
Standardmäßig nutzt wp_mail() die PHP-Funktion mail(), also den lokalen Sendmail. Bei vielen Hostern und VPS ist das ein Rezept dafür, dass Nachrichten im Spam landen oder gar nicht zugestellt werden. Besser ist der Versand über SMTP mit Authentifizierung, und für die Domain sollten Sie SPF, DKIM und DMARC einrichten. Dafür brauchen Sie kein Plugin – der Hook phpmailer_init genügt:
add_action('phpmailer_init', function (PHPMailer\PHPMailer\PHPMailer $mailer): void {
if (!defined('WL_SMTP_HOST')) {
return;
}
$mailer->isSMTP();
$mailer->Host = WL_SMTP_HOST;
$mailer->Port = WL_SMTP_PORT;
$mailer->SMTPAuth = true;
$mailer->Username = WL_SMTP_USER;
$mailer->Password = WL_SMTP_PASS;
$mailer->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
$mailer->setFrom(WL_SMTP_FROM, get_bloginfo('name'));
});
Definieren Sie die Konstanten WL_SMTP_* in der wp-config.php, damit das Passwort nicht im Repository landet. Prüfen Sie immer den Rückgabewert von wp_mail() – false bedeutet, dass PHPMailer die Nachricht nicht weitergegeben hat. Details zum Fehler erhalten Sie über den Hook wp_mail_failed.
So testen Sie es
Fügen Sie den Shortcode
[wl_contact]auf der Seite „Kontakt“ ein. Im Block-Editor verwenden Sie den Block „Shortcode“.Senden Sie ein korrektes Formular – es sollte die Erfolgsmeldung erscheinen, und in der Adresse steht
?kontakt=ok#kontakt.Laden Sie die Seite neu – die Nachricht darf nicht ein zweites Mal ankommen.
Füllen Sie in den Entwicklertools des Browsers das versteckte Feld
wl_websiteaus und senden Sie ab – Sie sehen „Erfolg“, aber die E-Mail kommt nicht an.Senden Sie das Formular innerhalb von 10 Minuten ein viertes Mal – Sie erhalten die Meldung zum Limit.
Verwenden Sie lokal statt echter Post Mailpit oder einen temporären Filter
pre_wp_mail, der die Nachricht in eine Datei schreibt undtruezurückgibt.
Wie es weitergehen kann
Eingaben nach einem Fehler behalten. Speichern Sie die Feldwerte in einem kurzlebigen Transient unter einem zufälligen Schlüssel, hängen Sie den Schlüssel an die Weiterleitungsadresse an und füllen Sie das Formular damit wieder aus.
Nachrichten in der Datenbank speichern. Ein eigener Inhaltstyp (
register_post_type) ohne Frontend liefert Ihnen eine Anfragenhistorie im Backend, selbst wenn die Mail ausfällt.Ein Block statt eines Shortcodes. Ein dynamischer Block mit
render_callbackkann dieselbe Funktionwl_contact_render()aufrufen.
Zum Schluss
Gut hundert Zeilen PHP genügen für ein Formular, das keine zusätzlichen Skripte oder Styles lädt und nur tut, was es soll. Kommen Nachrichten nicht an, beginne ich bei SMTP und den DNS-Einträgen, nicht beim Formularcode. Wenn Sie so ein Formular für Ihre Website oder Ihren Shop brauchen, schauen Sie sich die Seiten Webseiten und WooCommerce-Onlineshops an.
