Lesson 10/12 18 min

Project 10: a PHP form, and the real loop with AI

The capstone: a server-validated PHP contact form, built with AI — for real. The full loop, with its dead-ends and bugs, and the server-side security reflexes.

FR EN

Le projet : passer (enfin) côté serveur

Dixième et dernier projet : le capstone. Pour la première fois de la série, on quitte le navigateur pour le serveur. On construit un formulaire de contact en PHP, validé côté serveur. Rien de spectaculaire à l'écran — un nom, un email, un message — mais derrière, c'est un autre monde : c'est le serveur qui reçoit les données, et c'est lui seul qui décide ce qui est valable. Le navigateur, on ne lui fait plus confiance du tout.

Et puisque c'est le dernier, on change aussi de format. Les neuf projets précédents montraient la boucle « au propre », en 2 prompts. Ici, on te montre la vraie boucle, telle qu'elle se passe vraiment : en désordre, avec une impasse, un re-prompt qui rate, et un bug idiot qui fait perdre dix minutes. Parce que c'est ça, construire avec l'IA.

Différence importante : ce projet a besoin d'un serveur PHP pour tourner. Tu ne peux pas juste double-cliquer le fichier comme les précédents : il faut un hébergement qui exécute PHP (le tien, ou un PHP local). C'est le prix d'entrée du « côté serveur ».

Le journal de bord (la vraie vie)

Voici, dans l'ordre, ce qui s'est réellement passé. Pas une version idéalisée : les vrais détours.

Prompt 1 — on demande, large

En PHP, fais-moi un formulaire de contact (nom, email, message) avec validation et envoi par email.

L'IA sort un formulaire propre qui valide un peu et appelle mail() pour envoyer. Sur le papier, parfait. Je le mets sur mon hébergement mutualisé, j'envoie un test… et rien n'arrive (ou ça tombe direct en spam). Premier mur.

Le code de cette v1, en résumé :

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  // ⚠️ on prend les champs sans rien revérifier côté serveur
  $nom = $_POST['nom']; $email = $_POST['email']; $message = $_POST['message'];
  mail('contact@monsite.fr', "Message de $nom", $message, "From: $email");  // ⚠️ ne part pas sur mutualisé
  echo "Merci !";
}
?>
<form method="post">
  <input name="nom" required>                  <!-- "required" = confort navigateur, PAS une sécurité -->
  <input type="email" name="email" required>
  <textarea name="message" required></textarea>
  <button>Envoyer</button>
</form>

Prompt 2 — l'impasse

L'email n'arrive pas depuis mon hébergement mutualisé. Pourquoi ?

L'IA part dans la configuration SMTP, me propose d'installer PHPMailer, de gérer une clé d'API d'envoi… Tout ça est correct, mais pour un exercice, c'est un gouffre. C'est là qu'intervient une décision que l'IA ne prendra jamais à ta place : je change le périmètre. Pour cette démo, on n'enverra pas d'email du tout. On se concentre sur ce qui compte vraiment et qui se transpose partout : recevoir et valider proprement. L'envoi, ce sera un autre jour.

Prompt 3 — recadrer sur la validation

Oublie l'envoi d'email. Pour l'instant tu ne valides qu'avec l'attribut HTML "required" : mais un robot, ou un simple appel curl, ignorent complètement le HTML. Revalide TOUT côté serveur : nom (2 caractères min), format de l'email, message (10 caractères min), et réaffiche le formulaire avec les erreurs.

Beaucoup mieux : maintenant PHP revérifie chaque champ. Je teste en envoyant des bêtises avec un outil sans navigateur : c'est bien bloqué. Puis, par réflexe, je tape <script>alert(1)</script> dans le champ nom… et une alerte s'affiche quand le formulaire se réaffiche. Faille XSS.

Le code à cette étape — la validation serveur (mais elle réaffiche encore la saisie sans l'échapper) :

$errors = [];
$nom     = trim($_POST['nom'] ?? '');
$email   = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');

if (mb_strlen($nom) < 2)                        $errors['nom']     = "Nom trop court.";
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors['email']   = "Email invalide.";
if (mb_strlen($message) < 10)                   $errors['message'] = "Message trop court.";

// … puis on réaffiche le formulaire avec les valeurs saisies :
<input name="nom" value="<?= $nom ?>">   // ⚠️ réaffiché SANS échappement → le <script> s'exécute

Prompt 4 — colmater le XSS

Quand tu réaffiches les valeurs saisies (dans les value="…" et le récapitulatif), tu ne les échappes pas → une saisie comme <script> s'exécute. Passe TOUTE valeur affichée par htmlspecialchars.

Réglé : le <script> s'affiche désormais comme du texte, inoffensif.

// ❌ avant : la saisie est réinjectée telle quelle
<input name="nom" value="<?= $nom ?>">

// ✅ après : on échappe TOUTE valeur affichée
<input name="nom" value="<?= htmlspecialchars($nom, ENT_QUOTES) ?>">

Prompt 5 — CSRF et anti-spam

Ajoute une protection CSRF : un jeton aléatoire stocké en session, mis dans un champ caché, et vérifié à la soumission avec hash_equals. Ajoute aussi un honeypot (champ caché que seuls les robots remplissent).

Le code a l'air bon. Sauf qu'à l'exécution, le jeton est toujours invalide, même sans rien toucher. Dix minutes de perdues à fixer la zone… avant de comprendre : il y avait une ligne vide avant le <?php en haut du fichier. Cet espace est envoyé au navigateur avant session_start(), ce qui déclenche le fameux « headers already sent » et empêche la session de démarrer. On supprime la ligne. Tout marche.

Le code ajouté à cette étape (CSRF + honeypot) :

<?php
session_start();   // ⚠️ tout en haut, sans AUCUN espace ni ligne vide avant <?php
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));

// dans le formulaire :
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<input type="text" name="website" style="position:absolute;left:-9999px">  <!-- honeypot -->

// à la réception :
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) $errors['csrf'] = "Jeton invalide.";
if (!empty($_POST['website'])) { /* champ honeypot rempli = robot : on ignore */ }

Ce bug-là (un espace avant <?php qui casse les sessions/headers) est un grand classique de PHP. L'IA ne l'avait pas causé volontairement, et ne l'aurait pas deviné : c'est typiquement le genre de chose qu'on ne résout qu'en lisant le message d'erreur et en cherchant soi-même.

Bilan : cinq prompts, une impasse (l'email), une faille (XSS), un bug bête (la ligne vide). Voilà la vraie texture du travail avec l'IA. Les projets précédents te montraient la recette une fois réussie ; celui-ci te montre la cuisine.

Les réflexes serveur à retenir

Au-delà du désordre, trois principes ressortent, et ils valent pour toute appli qui a un serveur.

1. Ne jamais faire confiance au client

Le required et les types de champ HTML, c'est du confort pour l'utilisateur, pas de la sécurité. N'importe qui peut envoyer une requête sans passer par ton formulaire (un outil comme curl, un script, une extension). La seule validation qui compte est celle que fait le serveur : longueur, format (filter_var(..., FILTER_VALIDATE_EMAIL)), champs obligatoires. Le client propose, le serveur dispose.

2. Échapper la sortie (XSS)

Dès que tu réaffiches une donnée venue de l'utilisateur (dans un value="…", un récapitulatif, plus tard une page qui relit la base), tu l'échappes avec htmlspecialchars(..., ENT_QUOTES). Sinon, un <script> saisi s'exécute chez le prochain visiteur. C'est le même réflexe que le textContent côté JavaScript : frontière nette entre données et code.

3. Vérifier l'origine (CSRF) + ne pas exposer de secrets

Un jeton CSRF (aléatoire, en session, vérifié avec hash_equals) garantit que la soumission vient bien de ta page, pas d'un site tiers. Le honeypot filtre les robots à peu de frais. Et règle d'or : les secrets (identifiants SMTP, mots de passe de base, clés d'API) vivent dans un fichier de config hors du dossier public, jamais écrits en dur dans une page servie au navigateur.

Tester (comme un attaquant, pas comme un gentil utilisateur)

  • Envoie un nom trop court, un email bidon, un message vide : les erreurs serveur doivent s'afficher.
  • Tape <script>alert(1)</script> dans le nom et valide : ça doit s'afficher comme du texte, jamais déclencher d'alerte.
  • Soumets le formulaire sans passer par la page (avec curl, sans le jeton) : ça doit être refusé.
  • Recharge après une soumission : pas d'erreur « headers already sent » dans les logs, la session tient.

Le rendu final

Le formulaire, en vrai, tourne ici (côté serveur). Essaie de le tromper.

Ouvrir le projet en plein écran

Le code complet

Le fichier PHP entier, exactement celui qui tourne au-dessus. Contrairement aux projets précédents, on ne le « télécharge » pas pour l'ouvrir tel quel : un fichier PHP doit être exécuté par un serveur. Copie-le dans un fichier .php sur un hébergement PHP pour le voir vivre.

Voir le code complet (204 lignes)
<?php
// ============================================================================
// FORMULAIRE DE CONTACT VALIDÉ EN PHP — un seul fichier, qui tourne sur un SERVEUR.
// Contrairement aux projets précédents (tout dans le navigateur), ici c'est PHP
// qui reçoit les données, les VALIDE et décide quoi en faire. C'est le serveur
// qui a le dernier mot : on ne fait jamais confiance à ce qui vient du navigateur.
//
// Démo volontairement "sans suite" : on ne fait ni envoi d'email ni stockage
// (pour ne pas créer de spam ni de surface d'abus). On valide, puis on confirme.
// ============================================================================

session_start();

// --- Jeton CSRF -------------------------------------------------------------
// Un token secret, généré une fois et gardé en session. On le remet dans le
// formulaire (champ caché) ; à la soumission, on vérifie qu'il correspond.
// But : prouver que la requête vient bien de NOTRE page, pas d'un site tiers
// qui aurait piégé l'utilisateur (attaque CSRF).
if (empty($_SESSION['demo_csrf'])) {
    $_SESSION['demo_csrf'] = bin2hex(random_bytes(16));
}
$csrf = $_SESSION['demo_csrf'];

// --- Langue (fr par défaut) -------------------------------------------------
$lang = ((($_REQUEST['lang'] ?? 'fr')) === 'en') ? 'en' : 'fr';
$S = [
  'fr' => [
    'title' => "Formulaire de contact (PHP)", 'sub' => "Tout est validé côté serveur. Essaie de le tromper.",
    'nom' => "Nom", 'email' => "Email", 'message' => "Message", 'send' => "Envoyer",
    'ph_nom' => "Ton nom", 'ph_email' => "ton@email.fr", 'ph_msg' => "Ton message (au moins 10 caractères)…",
    'err_csrf' => "Jeton de sécurité invalide. Recharge la page et réessaie.",
    'err_nom' => "Indique un nom (2 caractères minimum).",
    'err_email' => "Cet email n'est pas valide.",
    'err_message' => "Le message doit faire au moins 10 caractères.",
    'ok_title' => "Message validé ✓", 'ok_text' => "En conditions réelles, il serait maintenant envoyé par email ou enregistré. Pour cette démo, on s'arrête là.",
    'recap' => "Récapitulatif (échappé, donc sûr à afficher) :",
    'again' => "Envoyer un autre message",
    'note' => "Le navigateur impose « required », mais c'est PHP qui revalide tout : un robot ou un outil comme curl ignore le HTML.",
    'credit' => 'Tourne sur un serveur PHP · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>'
  ],
  'en' => [
    'title' => "Contact form (PHP)", 'sub' => "Everything is validated on the server. Try to fool it.",
    'nom' => "Name", 'email' => "Email", 'message' => "Message", 'send' => "Send",
    'ph_nom' => "Your name", 'ph_email' => "you@email.com", 'ph_msg' => "Your message (at least 10 characters)…",
    'err_csrf' => "Invalid security token. Reload the page and try again.",
    'err_nom' => "Please enter a name (2 characters minimum).",
    'err_email' => "This email is not valid.",
    'err_message' => "The message must be at least 10 characters.",
    'ok_title' => "Message validated ✓", 'ok_text' => "In real conditions, it would now be emailed or stored. For this demo, we stop here.",
    'recap' => "Summary (escaped, so safe to display):",
    'again' => "Send another message",
    'note' => "The browser enforces \"required\", but PHP re-validates everything: a bot or a tool like curl ignores the HTML.",
    'credit' => 'Runs on a PHP server · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course'
  ],
][$lang];

// Échappe une valeur AVANT de l'afficher : la parade universelle contre le XSS.
// Si quelqu'un tape <script>…</script> dans un champ, ce sera affiché comme du
// texte, jamais exécuté.
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }

$errors = [];
$success = false;
$v = ['nom' => '', 'email' => '', 'message' => ''];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 1) Honeypot : un champ caché que seuls les robots remplissent. S'il est
    //    rempli, on fait comme si tout allait bien, sans rien traiter.
    if (!empty($_POST['website'])) {
        $success = true;
    }
    // 2) Jeton CSRF : doit correspondre à celui de la session (hash_equals évite
    //    les comparaisons de chaînes vulnérables au "timing").
    elseif (!isset($_POST['csrf']) || !hash_equals($csrf, $_POST['csrf'])) {
        $errors['csrf'] = $S['err_csrf'];
    }
    // 3) Validation CÔTÉ SERVEUR (le "required" HTML ne suffit pas : on revérifie tout).
    else {
        $v['nom']     = trim($_POST['nom'] ?? '');
        $v['email']   = trim($_POST['email'] ?? '');
        $v['message'] = trim($_POST['message'] ?? '');

        if (mb_strlen($v['nom']) < 2)                          $errors['nom'] = $S['err_nom'];
        if (!filter_var($v['email'], FILTER_VALIDATE_EMAIL))   $errors['email'] = $S['err_email'];
        if (mb_strlen($v['message']) < 10)                     $errors['message'] = $S['err_message'];

        // En vrai, ICI on enverrait l'email / on enregistrerait en base.
        // (Et les identifiants SMTP/BDD seraient dans un fichier de config HORS
        //  du dossier public, jamais écrits en dur dans cette page.)
        if (!$errors) { $success = true; }
    }
}
?>
<!DOCTYPE html>
<html lang="<?= e($lang) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($S['title']) ?></title>
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<style>
  :root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --warn:#a8341f; }
  * { box-sizing:border-box; }
  html,body { margin:0; padding:0; }
  body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
  .app { width:100%; max-width:520px; }
  header { text-align:center; margin-bottom:18px; }
  h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
  .sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
  .lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
  .lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; text-decoration:none; }
  .lang-btn.active { background:var(--accent); color:#fff; }
  .lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  form { background:#fff; border:1px solid var(--border); border-radius:14px; padding:22px; }
  label { display:block; font-weight:700; font-size:0.9rem; margin:14px 0 6px; }
  label:first-child { margin-top:0; }
  input[type=text], input[type=email], textarea { width:100%; padding:11px 13px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
  textarea { min-height:96px; resize:vertical; }
  input:focus-visible, textarea:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
  input.bad, textarea.bad { border-color:var(--warn); }
  .field-err { color:var(--warn); font-size:0.85rem; margin:6px 0 0; }
  .btn { margin-top:18px; width:100%; min-height:50px; border-radius:12px; border:0; background:var(--accent); color:#fff; font:inherit; font-weight:700; font-size:1rem; cursor:pointer; }
  .btn:hover { background:#1f6a37; }
  .btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
  .note { color:var(--muted); font-size:0.82rem; margin-top:14px; }

  .alert { border-radius:10px; padding:12px 14px; margin-bottom:16px; font-size:0.92rem; }
  .alert-err { background:#fdecea; color:var(--warn); border:1px solid #f3c4be; }
  .ok { background:#fff; border:1px solid var(--border); border-radius:14px; padding:24px; }
  .ok h2 { margin:0 0 8px; color:var(--accent); }
  .ok dl { background:#f4f6f8; border-radius:10px; padding:12px 14px; margin:14px 0; }
  .ok dt { font-size:0.72rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--muted); }
  .ok dd { margin:2px 0 10px; word-break:break-word; }
  .ok dd:last-child { margin-bottom:0; }
  .credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:22px; }
  .credit a { color:var(--accent); }
</style>
</head>
<body>
<main class="app">
  <header>
    <h1><?= e($S['title']) ?></h1>
    <p class="sub"><?= e($S['sub']) ?></p>
    <!-- Le changement de langue est un simple lien (?lang=…), géré par le serveur. -->
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <a class="lang-btn <?= $lang==='fr'?'active':'' ?>" href="?lang=fr">FR</a>
      <a class="lang-btn <?= $lang==='en'?'active':'' ?>" href="?lang=en">EN</a>
    </div>
  </header>

<?php if ($success): ?>
  <!-- SUCCÈS : on réaffiche les valeurs, mais ÉCHAPPÉES avec e() → aucun script ne s'exécute. -->
  <div class="ok">
    <h2><?= e($S['ok_title']) ?></h2>
    <p><?= e($S['ok_text']) ?></p>
    <?php if ($v['nom'] !== '' || $v['email'] !== ''): ?>
    <p style="margin:0;color:var(--muted);font-size:0.85rem;"><?= e($S['recap']) ?></p>
    <dl>
      <dt><?= e($S['nom']) ?></dt><dd><?= e($v['nom']) ?></dd>
      <dt><?= e($S['email']) ?></dt><dd><?= e($v['email']) ?></dd>
      <dt><?= e($S['message']) ?></dt><dd><?= e($v['message']) ?></dd>
    </dl>
    <?php endif; ?>
    <a class="lang-btn active" style="display:inline-block;padding:10px 18px;border-radius:10px;" href="?lang=<?= e($lang) ?>"><?= e($S['again']) ?></a>
  </div>
<?php else: ?>
  <form method="post" action="">
    <?php if (!empty($errors['csrf'])): ?>
      <div class="alert alert-err"><?= e($errors['csrf']) ?></div>
    <?php endif; ?>

    <!-- Champs cachés : le jeton CSRF et la langue voyagent avec le formulaire. -->
    <input type="hidden" name="csrf" value="<?= e($csrf) ?>">
    <input type="hidden" name="lang" value="<?= e($lang) ?>">

    <!-- Honeypot : invisible pour un humain, souvent rempli par un robot. -->
    <div style="position:absolute;left:-9999px;" aria-hidden="true">
      <label>Ne pas remplir<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
    </div>

    <label for="nom"><?= e($S['nom']) ?></label>
    <input type="text" id="nom" name="nom" value="<?= e($v['nom']) ?>" placeholder="<?= e($S['ph_nom']) ?>" class="<?= isset($errors['nom'])?'bad':'' ?>" required>
    <?php if (isset($errors['nom'])): ?><p class="field-err"><?= e($errors['nom']) ?></p><?php endif; ?>

    <label for="email"><?= e($S['email']) ?></label>
    <input type="email" id="email" name="email" value="<?= e($v['email']) ?>" placeholder="<?= e($S['ph_email']) ?>" class="<?= isset($errors['email'])?'bad':'' ?>" required>
    <?php if (isset($errors['email'])): ?><p class="field-err"><?= e($errors['email']) ?></p><?php endif; ?>

    <label for="message"><?= e($S['message']) ?></label>
    <textarea id="message" name="message" placeholder="<?= e($S['ph_msg']) ?>" class="<?= isset($errors['message'])?'bad':'' ?>" required><?= e($v['message']) ?></textarea>
    <?php if (isset($errors['message'])): ?><p class="field-err"><?= e($errors['message']) ?></p><?php endif; ?>

    <button class="btn" type="submit"><?= e($S['send']) ?></button>
    <p class="note"><?= e($S['note']) ?></p>
  </form>
<?php endif; ?>

  <p class="credit"><?= $S['credit'] /* contient un lien <a> rédigé par nous, pas par l'utilisateur */ ?></p>
</main>
</body>
</html>

À toi de jouer

  • Branche un vrai envoi d'email (cherche PHPMailer + un service SMTP), en gardant les identifiants hors du code.
  • Enregistre les messages dans une base (et repense au projet 9 : requêtes paramétrées).
  • Limite le nombre d'envois par minute (anti-abus).

À chaque ajout, la question serveur ne change pas : est-ce que je fais confiance à ce qui arrive ? (non) Est-ce que j'échappe ce que je renvoie ? (oui)

Le capstone est bouclé

Dix projets, du HTML statique au serveur PHP. À chaque fois le même réflexe : l'IA code vite, l'humain relit et décide. Et tu as vu, avec celui-ci, à quoi ça ressemble vraiment : détours, impasses et bug idiot compris. Mais le serveur, on ne fait que l'ouvrir. La suite va plus loin : garder les données dans une vraie base.

Projet 11 : un livre d'or qui garde tout →

The project: (finally) going server-side

Tenth and last project: the capstone. For the first time in the series, we leave the browser for the server. We build a PHP contact form, validated on the server. Nothing spectacular on screen — a name, an email, a message — but behind it, it's another world: the server receives the data, and it alone decides what's valid. The browser, we no longer trust at all.

And since it's the last one, we change format too. The previous nine projects showed the loop "tidied up", in 2 prompts. Here, we show you the real loop, as it actually happens: messy, with a dead-end, a re-prompt that misses, and a silly bug that costs ten minutes. Because that's what building with AI is.

Important difference: this project needs a PHP server to run. You can't just double-click the file like the previous ones: you need hosting that executes PHP (yours, or a local PHP). That's the entry price of "server-side".

The logbook (real life)

Here, in order, is what actually happened. Not an idealized version: the real detours.

Prompt 1 — we ask, broadly

In PHP, make me a contact form (name, email, message) with validation and email sending.

The AI outputs a clean form that validates a bit and calls mail() to send. On paper, perfect. I put it on my shared hosting, send a test… and nothing arrives (or it lands straight in spam). First wall.

This v1, in short:

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  // ⚠️ we take the fields without re-checking anything on the server
  $nom = $_POST['nom']; $email = $_POST['email']; $message = $_POST['message'];
  mail('contact@monsite.fr', "Message from $nom", $message, "From: $email");  // ⚠️ won't send on shared hosting
  echo "Thanks!";
}
?>
<form method="post">
  <input name="nom" required>                  <!-- "required" = browser comfort, NOT security -->
  <input type="email" name="email" required>
  <textarea name="message" required></textarea>
  <button>Send</button>
</form>

Prompt 2 — the dead-end

The email doesn't arrive from my shared hosting. Why?

The AI dives into SMTP configuration, suggests installing PHPMailer, handling a sending API key… All correct, but for an exercise, it's a rabbit hole. That's where a decision the AI will never make for you comes in: I change the scope. For this demo, we won't send any email at all. We focus on what really matters and transfers everywhere: receiving and validating cleanly. Sending is for another day.

Prompt 3 — refocus on validation

Forget the email sending. For now you only validate with the HTML "required" attribute: but a bot, or a simple curl call, completely ignore the HTML. Re-validate EVERYTHING on the server: name (2 chars min), email format, message (10 chars min), and redisplay the form with the errors.

Much better: now PHP re-checks every field. I test by sending junk with a browser-less tool: properly blocked. Then, out of reflex, I type <script>alert(1)</script> in the name field… and an alert pops up when the form redisplays. XSS flaw.

The code at this step — server validation (but it still redisplays the input without escaping it):

$errors = [];
$nom     = trim($_POST['nom'] ?? '');
$email   = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');

if (mb_strlen($nom) < 2)                        $errors['nom']     = "Name too short.";
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errors['email']   = "Invalid email.";
if (mb_strlen($message) < 10)                   $errors['message'] = "Message too short.";

// … then we redisplay the form with the typed values:
<input name="nom" value="<?= $nom ?>">   // ⚠️ redisplayed WITHOUT escaping → the <script> runs

Prompt 4 — plug the XSS

When you redisplay the typed values (in the value="…" and the summary), you don't escape them → an input like <script> runs. Pass EVERY displayed value through htmlspecialchars.

Fixed: the <script> now shows as text, harmless.

// ❌ before: the input is re-injected as-is
<input name="nom" value="<?= $nom ?>">

// ✅ after: we escape EVERY displayed value
<input name="nom" value="<?= htmlspecialchars($nom, ENT_QUOTES) ?>">

Prompt 5 — CSRF and anti-spam

Add CSRF protection: a random token stored in the session, put in a hidden field, and verified on submit with hash_equals. Also add a honeypot (hidden field only bots fill).

The code looks good. Except at runtime, the token is always invalid, even without touching anything. Ten minutes lost staring at the area… before realizing: there was a blank line before the <?php at the top of the file. That space is sent to the browser before session_start(), which triggers the infamous "headers already sent" and stops the session from starting. We remove the line. Everything works.

The code added at this step (CSRF + honeypot):

<?php
session_start();   // ⚠️ right at the top, with NO space or blank line before <?php
if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(16));

// in the form:
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<input type="text" name="website" style="position:absolute;left:-9999px">  <!-- honeypot -->

// on receipt:
if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) $errors['csrf'] = "Invalid token.";
if (!empty($_POST['website'])) { /* honeypot filled = bot: ignore */ }

That bug (a space before <?php breaking sessions/headers) is a great PHP classic. The AI didn't cause it on purpose, and wouldn't have guessed it: it's exactly the kind of thing you only solve by reading the error message and digging yourself.

Tally: five prompts, a dead-end (the email), a flaw (XSS), a silly bug (the blank line). That's the real texture of working with AI. The previous projects showed you the recipe once it worked; this one shows you the kitchen.

The server reflexes to keep

Beyond the mess, three principles stand out, and they hold for any app that has a server.

1. Never trust the client

HTML required and field types are comfort for the user, not security. Anyone can send a request without going through your form (a tool like curl, a script, an extension). The only validation that counts is the one the server does: length, format (filter_var(..., FILTER_VALIDATE_EMAIL)), required fields. The client proposes, the server disposes.

2. Escape the output (XSS)

As soon as you redisplay user data (in a value="…", a summary, later a page that reads the database), escape it with htmlspecialchars(..., ENT_QUOTES). Otherwise, a typed <script> runs for the next visitor. It's the same reflex as textContent on the JavaScript side: a sharp border between data and code.

3. Check the origin (CSRF) + don't expose secrets

A CSRF token (random, in the session, verified with hash_equals) guarantees the submission really comes from your page, not a third-party site. The honeypot filters bots cheaply. And the golden rule: secrets (SMTP credentials, database passwords, API keys) live in a config file outside the public folder, never written in a page served to the browser.

Test (like an attacker, not a nice user)

  • Send a too-short name, a fake email, an empty message: the server errors must show.
  • Type <script>alert(1)</script> in the name and submit: it must show as text, never trigger an alert.
  • Submit the form without going through the page (with curl, no token): it must be refused.
  • Reload after a submission: no "headers already sent" in the logs, the session holds.

The finished result

The form, for real, runs here (server-side). Try to fool it.

Open the project full screen

The full code

The entire PHP file, exactly the one running above. Unlike the previous projects, you don't "download" it to open as-is: a PHP file must be run by a server. Copy it into a .php file on PHP hosting to see it live.

View the full code (204 lines)
<?php
// ============================================================================
// FORMULAIRE DE CONTACT VALIDÉ EN PHP — un seul fichier, qui tourne sur un SERVEUR.
// Contrairement aux projets précédents (tout dans le navigateur), ici c'est PHP
// qui reçoit les données, les VALIDE et décide quoi en faire. C'est le serveur
// qui a le dernier mot : on ne fait jamais confiance à ce qui vient du navigateur.
//
// Démo volontairement "sans suite" : on ne fait ni envoi d'email ni stockage
// (pour ne pas créer de spam ni de surface d'abus). On valide, puis on confirme.
// ============================================================================

session_start();

// --- Jeton CSRF -------------------------------------------------------------
// Un token secret, généré une fois et gardé en session. On le remet dans le
// formulaire (champ caché) ; à la soumission, on vérifie qu'il correspond.
// But : prouver que la requête vient bien de NOTRE page, pas d'un site tiers
// qui aurait piégé l'utilisateur (attaque CSRF).
if (empty($_SESSION['demo_csrf'])) {
    $_SESSION['demo_csrf'] = bin2hex(random_bytes(16));
}
$csrf = $_SESSION['demo_csrf'];

// --- Langue (fr par défaut) -------------------------------------------------
$lang = ((($_REQUEST['lang'] ?? 'fr')) === 'en') ? 'en' : 'fr';
$S = [
  'fr' => [
    'title' => "Formulaire de contact (PHP)", 'sub' => "Tout est validé côté serveur. Essaie de le tromper.",
    'nom' => "Nom", 'email' => "Email", 'message' => "Message", 'send' => "Envoyer",
    'ph_nom' => "Ton nom", 'ph_email' => "ton@email.fr", 'ph_msg' => "Ton message (au moins 10 caractères)…",
    'err_csrf' => "Jeton de sécurité invalide. Recharge la page et réessaie.",
    'err_nom' => "Indique un nom (2 caractères minimum).",
    'err_email' => "Cet email n'est pas valide.",
    'err_message' => "Le message doit faire au moins 10 caractères.",
    'ok_title' => "Message validé ✓", 'ok_text' => "En conditions réelles, il serait maintenant envoyé par email ou enregistré. Pour cette démo, on s'arrête là.",
    'recap' => "Récapitulatif (échappé, donc sûr à afficher) :",
    'again' => "Envoyer un autre message",
    'note' => "Le navigateur impose « required », mais c'est PHP qui revalide tout : un robot ou un outil comme curl ignore le HTML.",
    'credit' => 'Tourne sur un serveur PHP · Mini-projet du cours <a href="/apprendre/projets/">Projets appliqués</a>'
  ],
  'en' => [
    'title' => "Contact form (PHP)", 'sub' => "Everything is validated on the server. Try to fool it.",
    'nom' => "Name", 'email' => "Email", 'message' => "Message", 'send' => "Send",
    'ph_nom' => "Your name", 'ph_email' => "you@email.com", 'ph_msg' => "Your message (at least 10 characters)…",
    'err_csrf' => "Invalid security token. Reload the page and try again.",
    'err_nom' => "Please enter a name (2 characters minimum).",
    'err_email' => "This email is not valid.",
    'err_message' => "The message must be at least 10 characters.",
    'ok_title' => "Message validated ✓", 'ok_text' => "In real conditions, it would now be emailed or stored. For this demo, we stop here.",
    'recap' => "Summary (escaped, so safe to display):",
    'again' => "Send another message",
    'note' => "The browser enforces \"required\", but PHP re-validates everything: a bot or a tool like curl ignores the HTML.",
    'credit' => 'Runs on a PHP server · A mini-project from the <a href="/apprendre/projets/">Applied projects</a> course'
  ],
][$lang];

// Échappe une valeur AVANT de l'afficher : la parade universelle contre le XSS.
// Si quelqu'un tape <script>…</script> dans un champ, ce sera affiché comme du
// texte, jamais exécuté.
function e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }

$errors = [];
$success = false;
$v = ['nom' => '', 'email' => '', 'message' => ''];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // 1) Honeypot : un champ caché que seuls les robots remplissent. S'il est
    //    rempli, on fait comme si tout allait bien, sans rien traiter.
    if (!empty($_POST['website'])) {
        $success = true;
    }
    // 2) Jeton CSRF : doit correspondre à celui de la session (hash_equals évite
    //    les comparaisons de chaînes vulnérables au "timing").
    elseif (!isset($_POST['csrf']) || !hash_equals($csrf, $_POST['csrf'])) {
        $errors['csrf'] = $S['err_csrf'];
    }
    // 3) Validation CÔTÉ SERVEUR (le "required" HTML ne suffit pas : on revérifie tout).
    else {
        $v['nom']     = trim($_POST['nom'] ?? '');
        $v['email']   = trim($_POST['email'] ?? '');
        $v['message'] = trim($_POST['message'] ?? '');

        if (mb_strlen($v['nom']) < 2)                          $errors['nom'] = $S['err_nom'];
        if (!filter_var($v['email'], FILTER_VALIDATE_EMAIL))   $errors['email'] = $S['err_email'];
        if (mb_strlen($v['message']) < 10)                     $errors['message'] = $S['err_message'];

        // En vrai, ICI on enverrait l'email / on enregistrerait en base.
        // (Et les identifiants SMTP/BDD seraient dans un fichier de config HORS
        //  du dossier public, jamais écrits en dur dans cette page.)
        if (!$errors) { $success = true; }
    }
}
?>
<!DOCTYPE html>
<html lang="<?= e($lang) ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($S['title']) ?></title>
<meta name="robots" content="noindex, follow">
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<style>
  :root { --ink:#1a1d24; --muted:#5a6270; --accent:#267d42; --border:#e2e6ea; --warn:#a8341f; }
  * { box-sizing:border-box; }
  html,body { margin:0; padding:0; }
  body { font-family:'Segoe UI',system-ui,-apple-system,Roboto,Helvetica,Arial,sans-serif; background:#f4f6f8; color:var(--ink); min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:32px 18px 48px; line-height:1.5; }
  .app { width:100%; max-width:520px; }
  header { text-align:center; margin-bottom:18px; }
  h1 { font-size:1.5rem; margin:0 0 6px; letter-spacing:-0.01em; }
  .sub { color:var(--muted); font-size:0.95rem; margin:0 0 16px; }
  .lang-switch { display:inline-flex; border:1.5px solid var(--border); border-radius:999px; overflow:hidden; background:#fff; }
  .lang-btn { border:0; background:transparent; padding:7px 18px; font:inherit; font-weight:700; font-size:0.8rem; color:var(--muted); cursor:pointer; text-decoration:none; }
  .lang-btn.active { background:var(--accent); color:#fff; }
  .lang-btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }

  form { background:#fff; border:1px solid var(--border); border-radius:14px; padding:22px; }
  label { display:block; font-weight:700; font-size:0.9rem; margin:14px 0 6px; }
  label:first-child { margin-top:0; }
  input[type=text], input[type=email], textarea { width:100%; padding:11px 13px; border:1.5px solid var(--border); border-radius:10px; font:inherit; font-size:1rem; color:var(--ink); }
  textarea { min-height:96px; resize:vertical; }
  input:focus-visible, textarea:focus-visible { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px rgba(38,125,66,0.12); }
  input.bad, textarea.bad { border-color:var(--warn); }
  .field-err { color:var(--warn); font-size:0.85rem; margin:6px 0 0; }
  .btn { margin-top:18px; width:100%; min-height:50px; border-radius:12px; border:0; background:var(--accent); color:#fff; font:inherit; font-weight:700; font-size:1rem; cursor:pointer; }
  .btn:hover { background:#1f6a37; }
  .btn:focus-visible { outline:3px solid rgba(38,125,66,0.4); outline-offset:2px; }
  .note { color:var(--muted); font-size:0.82rem; margin-top:14px; }

  .alert { border-radius:10px; padding:12px 14px; margin-bottom:16px; font-size:0.92rem; }
  .alert-err { background:#fdecea; color:var(--warn); border:1px solid #f3c4be; }
  .ok { background:#fff; border:1px solid var(--border); border-radius:14px; padding:24px; }
  .ok h2 { margin:0 0 8px; color:var(--accent); }
  .ok dl { background:#f4f6f8; border-radius:10px; padding:12px 14px; margin:14px 0; }
  .ok dt { font-size:0.72rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--muted); }
  .ok dd { margin:2px 0 10px; word-break:break-word; }
  .ok dd:last-child { margin-bottom:0; }
  .credit { text-align:center; color:var(--muted); font-size:0.78rem; margin-top:22px; }
  .credit a { color:var(--accent); }
</style>
</head>
<body>
<main class="app">
  <header>
    <h1><?= e($S['title']) ?></h1>
    <p class="sub"><?= e($S['sub']) ?></p>
    <!-- Le changement de langue est un simple lien (?lang=…), géré par le serveur. -->
    <div class="lang-switch" role="group" aria-label="Langue / Language">
      <a class="lang-btn <?= $lang==='fr'?'active':'' ?>" href="?lang=fr">FR</a>
      <a class="lang-btn <?= $lang==='en'?'active':'' ?>" href="?lang=en">EN</a>
    </div>
  </header>

<?php if ($success): ?>
  <!-- SUCCÈS : on réaffiche les valeurs, mais ÉCHAPPÉES avec e() → aucun script ne s'exécute. -->
  <div class="ok">
    <h2><?= e($S['ok_title']) ?></h2>
    <p><?= e($S['ok_text']) ?></p>
    <?php if ($v['nom'] !== '' || $v['email'] !== ''): ?>
    <p style="margin:0;color:var(--muted);font-size:0.85rem;"><?= e($S['recap']) ?></p>
    <dl>
      <dt><?= e($S['nom']) ?></dt><dd><?= e($v['nom']) ?></dd>
      <dt><?= e($S['email']) ?></dt><dd><?= e($v['email']) ?></dd>
      <dt><?= e($S['message']) ?></dt><dd><?= e($v['message']) ?></dd>
    </dl>
    <?php endif; ?>
    <a class="lang-btn active" style="display:inline-block;padding:10px 18px;border-radius:10px;" href="?lang=<?= e($lang) ?>"><?= e($S['again']) ?></a>
  </div>
<?php else: ?>
  <form method="post" action="">
    <?php if (!empty($errors['csrf'])): ?>
      <div class="alert alert-err"><?= e($errors['csrf']) ?></div>
    <?php endif; ?>

    <!-- Champs cachés : le jeton CSRF et la langue voyagent avec le formulaire. -->
    <input type="hidden" name="csrf" value="<?= e($csrf) ?>">
    <input type="hidden" name="lang" value="<?= e($lang) ?>">

    <!-- Honeypot : invisible pour un humain, souvent rempli par un robot. -->
    <div style="position:absolute;left:-9999px;" aria-hidden="true">
      <label>Ne pas remplir<input type="text" name="website" tabindex="-1" autocomplete="off"></label>
    </div>

    <label for="nom"><?= e($S['nom']) ?></label>
    <input type="text" id="nom" name="nom" value="<?= e($v['nom']) ?>" placeholder="<?= e($S['ph_nom']) ?>" class="<?= isset($errors['nom'])?'bad':'' ?>" required>
    <?php if (isset($errors['nom'])): ?><p class="field-err"><?= e($errors['nom']) ?></p><?php endif; ?>

    <label for="email"><?= e($S['email']) ?></label>
    <input type="email" id="email" name="email" value="<?= e($v['email']) ?>" placeholder="<?= e($S['ph_email']) ?>" class="<?= isset($errors['email'])?'bad':'' ?>" required>
    <?php if (isset($errors['email'])): ?><p class="field-err"><?= e($errors['email']) ?></p><?php endif; ?>

    <label for="message"><?= e($S['message']) ?></label>
    <textarea id="message" name="message" placeholder="<?= e($S['ph_msg']) ?>" class="<?= isset($errors['message'])?'bad':'' ?>" required><?= e($v['message']) ?></textarea>
    <?php if (isset($errors['message'])): ?><p class="field-err"><?= e($errors['message']) ?></p><?php endif; ?>

    <button class="btn" type="submit"><?= e($S['send']) ?></button>
    <p class="note"><?= e($S['note']) ?></p>
  </form>
<?php endif; ?>

  <p class="credit"><?= $S['credit'] /* contient un lien <a> rédigé par nous, pas par l'utilisateur */ ?></p>
</main>
</body>
</html>

Your turn

  • Wire up real email sending (look up PHPMailer + an SMTP service), keeping credentials out of the code.
  • Store messages in a database (and remember project 9: parameterized queries).
  • Rate-limit submissions per minute (anti-abuse).

On every addition, the server question doesn't change: do I trust what comes in? (no) Do I escape what I send back? (yes)

The capstone is done

Ten projects, from static HTML to a PHP server. Each time the same reflex: the AI codes fast, the human reviews and decides. And with this one, you saw what it really looks like: detours, dead-ends and silly bug included. But the server, we're only opening the door. What's next goes further: keeping the data in a real database.

Project 11: a guestbook that keeps everything →
Next step

Your PHP form validates data on the server, where it really matters. Next up, you make it all persist: a guestbook that keeps every message in a SQLite database, even after you close the page.

Lesson 11: Guestbook (SQLite) →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement