Un formulaire de contact en PHP, c'est quelques champs et un appel à mail()… et beaucoup de pièges : spam, envois frauduleux, messages perdus, failles de sécurité. Tu vas construire un formulaire complet en HTML, CSS et PHP, qui valide la saisie, se protège des robots et envoie un e-mail auquel tu peux répondre en un clic. Durée : environ 1 heure.
Ce que tu vas construire
- un formulaire (nom, e-mail, message) accessible, avec des messages d'erreur sous chaque champ ;
- une validation côté serveur, qui garde la saisie en cas d'erreur ;
- une protection contre les envois frauduleux (jeton CSRF) et contre les robots (champ piège) ;
- un e-mail dont le bouton « Répondre » écrit directement au visiteur ;
- une page de confirmation qui ne renvoie pas le message si on l'actualise.
Il te faut PHP 8 ou plus. Crée un dossier avec index.php et style.css.
1. Le code complet
<?php
declare(strict_types=1);
session_start();
const DESTINATAIRE = 'toi@example.com'; // Ton adresse : les messages arrivent ici.
const EXPEDITEUR = 'site@example.com'; // Une adresse de TON nom de domaine.
function e(string $texte): string
{
return htmlspecialchars($texte, ENT_QUOTES, 'UTF-8');
}
// Un jeton secret par visiteur : seul notre formulaire peut envoyer des messages (protection CSRF).
$_SESSION['jeton'] ??= bin2hex(random_bytes(32));
$valeurs = ['nom' => '', 'email' => '', 'message' => ''];
$erreurs = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Champ piège, invisible pour un humain : un robot le remplit.
// On lui fait croire que tout s'est bien passé, sans rien envoyer.
if (($_POST['site_web'] ?? '') !== '') {
header('Location: ?envoye=1', true, 303);
exit;
}
foreach ($valeurs as $champ => $_) {
$valeurs[$champ] = trim((string) ($_POST[$champ] ?? ''));
}
if (!hash_equals($_SESSION['jeton'], (string) ($_POST['jeton'] ?? ''))) {
$erreurs['formulaire'] = 'La page a expiré : recharge-la puis renvoie ton message.';
}
// Pas de retour à la ligne dans le nom : il finit dans le sujet de l'e-mail.
if ($valeurs['nom'] === '' || mb_strlen($valeurs['nom']) > 100 || preg_match('/[\r\n]/', $valeurs['nom'])) {
$erreurs['nom'] = 'Indique ton nom (100 caractères au plus).';
}
if (filter_var($valeurs['email'], FILTER_VALIDATE_EMAIL) === false) {
$erreurs['email'] = 'Indique une adresse e-mail valide, pour que je puisse te répondre.';
}
if (mb_strlen($valeurs['message']) < 10 || mb_strlen($valeurs['message']) > 5000) {
$erreurs['message'] = 'Ton message doit faire entre 10 et 5 000 caractères.';
}
if ($erreurs === []) {
$sujet = 'Message de ' . $valeurs['nom'] . ' via le site';
$corps = "Nom : {$valeurs['nom']}\nE-mail : {$valeurs['email']}\n\n{$valeurs['message']}";
$entetes = [
'From' => EXPEDITEUR,
'Reply-To' => $valeurs['email'], // « Répondre » écrit directement au visiteur.
'Content-Type' => 'text/plain; charset=UTF-8',
];
if (mail(DESTINATAIRE, mb_encode_mimeheader($sujet, 'UTF-8'), $corps, $entetes)) {
// Redirection après envoi : actualiser la page ne renvoie pas le message.
header('Location: ?envoye=1', true, 303);
exit;
}
$erreurs['formulaire'] = 'Le message n\'a pas pu partir. Réessaie dans quelques minutes.';
}
}
$envoye = isset($_GET['envoye']);
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="app">
<h1>Me contacter</h1>
<?php if ($envoye): ?>
<p class="succes" role="status">Merci, ton message est bien parti ! Je te réponds sous 48 heures.</p>
<?php else: ?>
<?php if (isset($erreurs['formulaire'])): ?>
<p class="erreur" role="alert"><?= e($erreurs['formulaire']) ?></p>
<?php endif; ?>
<form method="post" novalidate>
<input type="hidden" name="jeton" value="<?= e($_SESSION['jeton']) ?>">
<label for="nom">Nom</label>
<input id="nom" name="nom" autocomplete="name" required value="<?= e($valeurs['nom']) ?>"
<?= isset($erreurs['nom']) ? 'aria-invalid="true" aria-describedby="nom-erreur"' : '' ?>>
<?php if (isset($erreurs['nom'])): ?><p id="nom-erreur" class="erreur"><?= e($erreurs['nom']) ?></p><?php endif; ?>
<label for="email">E-mail</label>
<input id="email" name="email" type="email" autocomplete="email" required value="<?= e($valeurs['email']) ?>"
<?= isset($erreurs['email']) ? 'aria-invalid="true" aria-describedby="email-erreur"' : '' ?>>
<?php if (isset($erreurs['email'])): ?><p id="email-erreur" class="erreur"><?= e($erreurs['email']) ?></p><?php endif; ?>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required
<?= isset($erreurs['message']) ? 'aria-invalid="true" aria-describedby="message-erreur"' : '' ?>><?= e($valeurs['message']) ?></textarea>
<?php if (isset($erreurs['message'])): ?><p id="message-erreur" class="erreur"><?= e($erreurs['message']) ?></p><?php endif; ?>
<!-- Champ piège : caché aux humains, rempli par les robots. -->
<div class="piege" aria-hidden="true">
<label for="site_web">Ne pas remplir</label>
<input id="site_web" name="site_web" tabindex="-1" autocomplete="off">
</div>
<button type="submit">Envoyer</button>
</form>
<?php endif; ?>
</main>
</body>
</html>
Remplace les deux adresses en haut du fichier. EXPEDITEUR doit appartenir au nom de domaine de ton site : un message « envoyé par » une adresse Gmail depuis ton serveur serait considéré comme usurpé et finirait en spam. L'adresse du visiteur va dans Reply-To.
2. Ce que fait chaque partie
La validation
- Chaque champ est nettoyé avec
trim(), puis vérifié : nom obligatoire, adresse valide avecfilter_var(…, FILTER_VALIDATE_EMAIL), message entre 10 et 5 000 caractères. - En cas d'erreur, le formulaire réaffiche la saisie, échappée avec
htmlspecialchars(): un visiteur qui tape du HTML ne peut pas l'injecter dans la page. - Les attributs
requiredettype="email"aident sur le téléphone (clavier adapté), maisnovalidatelaisse le serveur afficher ses propres messages. La validation du navigateur se contourne en une seconde : seule celle du serveur protège.
Le jeton CSRF
Sans protection, n'importe quel site pourrait faire envoyer ton formulaire par le navigateur de ses visiteurs. Le jeton est une valeur aléatoire (random_bytes) gardée en session et glissée dans un champ caché : le serveur refuse tout envoi qui ne la renvoie pas. hash_equals() compare les deux valeurs en temps constant, pour ne rien laisser deviner.
Le champ piège (honeypot)
Les robots remplissent tous les champs qu'ils trouvent. Le champ site_web est placé hors de l'écran, sans tabulation possible et caché aux lecteurs d'écran : un humain le laisse vide. S'il est rempli, on fait croire au robot que tout s'est bien passé, sans rien envoyer. C'est simple, invisible pour les visiteurs, et étonnamment efficace.
L'injection d'en-têtes
Le nom du visiteur finit dans le sujet de l'e-mail. S'il contenait un retour à la ligne, un pirate pourrait ajouter des en-têtes (Bcc:…) et utiliser ton serveur pour envoyer du spam. D'où le refus des caractères \r et \n dans le nom. L'adresse, elle, est déjà protégée par FILTER_VALIDATE_EMAIL. mb_encode_mimeheader() encode le sujet pour que les accents s'affichent correctement.
La redirection après l'envoi
Après un envoi réussi, header('Location: ?envoye=1', true, 303) redirige vers la page de confirmation. Sans cette redirection, actualiser la page renverrait le formulaire, et le message partirait deux fois. C'est le motif « Post/Redirect/Get ».
3. Le CSS
body {
margin: 0;
background: #f6f4ef;
font-family: system-ui, sans-serif;
line-height: 1.5;
}
.app {
max-width: 36rem;
margin: 0 auto;
padding: 2rem 1rem;
}
label {
display: block;
margin-top: 1rem;
font-weight: 600;
}
input,
textarea {
box-sizing: border-box;
width: 100%;
padding: 0.5rem;
border: 1px solid #bbb;
border-radius: 6px;
font: inherit;
}
input:focus-visible,
textarea:focus-visible {
outline: 3px solid #25737d;
outline-offset: 1px;
}
[aria-invalid="true"] {
border-color: #b3261e;
}
.erreur {
margin: 0.25rem 0 0;
color: #b3261e;
font-size: 0.875rem;
}
.succes {
padding: 1rem;
border-radius: 8px;
background: #e3f1ec;
color: #1c5b45;
}
/* Hors de l'écran plutôt que display: none, que certains robots repèrent. */
.piege {
position: absolute;
left: -9999px;
}
button {
margin-top: 1.5rem;
padding: 0.6rem 1.25rem;
border: 0;
border-radius: 6px;
background: #25737d;
color: #fff;
font: inherit;
cursor: pointer;
}
4. Tester
En local, mail() n'a pas de serveur d'envoi. Pour voir les messages, lance le serveur intégré en redirigeant les e-mails vers un fichier :
php -d sendmail_path="tee -a mails.log" -S localhost:8000
- Envoie le formulaire vide : trois messages d'erreur.
- Remplis-le avec une adresse invalide : seule l'erreur de l'e-mail reste, le reste de la saisie est conservé.
- Corrige l'adresse : la page de confirmation s'affiche et le message apparaît dans
mails.log, avec ton adresse enReply-To. Actualise la page : rien n'est renvoyé.
5. En production
- Préfère un vrai service d'envoi. Sur beaucoup d'hébergements,
mail()est désactivé ou mal configuré, et ses messages arrivent souvent en spam. La bibliothèque PHPMailer (ou Symfony Mailer) envoie par SMTP, via ton hébergeur ou un service comme Brevo. - Configure SPF et DKIM sur ton nom de domaine : ce sont eux qui prouvent aux messageries que ton serveur a le droit d'envoyer en ton nom.
- Limite les envois : par exemple trois messages par adresse IP et par heure, pour décourager les abus.
- Le RGPD : indique sous le formulaire à quoi servent les données et combien de temps tu les gardes, et ne conserve pas les messages plus longtemps que nécessaire.
Pour aller plus loin
- Valider aussi dans le navigateur, pour un retour immédiat pendant la saisie : c'est le tutoriel validation de formulaires en JavaScript.
- Joindre un fichier au message : les précautions à prendre sont dans le tutoriel upload de fichiers en PHP.
- Enregistrer les messages en base de données, pour ne jamais en perdre un : les bases sont dans le tutoriel système de commentaires en PHP et MySQL.
Ton site a désormais un formulaire de contact digne de ce nom ? Présente-le dans la vitrine des lecteurs.