Refonte d'un site vitrine en PHP pur : bilingue, anti-spam, CI/CD — sans framework

Quatre fabricants d'équipement viticole. Un site vitrine vieillissant. Le brief : "on veut un truc propre, bilingue, qui marche sur mobile et qui ne se fait pas spammer." Pas d'espace client, pas d'e-commerce, pas de back-office. Cinq pages. La réaction naturelle en 2026, c'est de dégainer WordPress, Symfony ou Next.js. J'ai choisi PHP 8 brut, Bootstrap 5 via CDN, et zéro dépendance npm en production. 25 commits et trois semaines plus tard, le site tourne sur un mutualisé InterServer sans rien demander à personne.

Ce n'est pas un article sur "pourquoi PHP c'est bien". C'est un retour sur les vrais problèmes qu'on rencontre quand on fait un site vitrine sérieux sans framework : le bilingue qui piège Google, l'anti-spam qui n'agresse pas les humains, la CSP qui casse les CDN, le LCP qui plonge à cause d'un loading="lazy" mal placé, et la CI/CD qui teste un sitemap sans Apache.

Pourquoi pas WordPress, pourquoi pas Symfony

La question revient systématiquement. Cinq pages statiques, un formulaire de contact, pas de contenu dynamique. Qu'est-ce qu'un CMS apporte ici ? Un panel d'administration que personne n'utilisera, un système de plugins dont 40% ont des CVE publiées, et une mise à jour mensuelle qui casse le thème. WordPress représente plus de 7 000 CVE référencées. Pour un site qui ne changera pas avant 3 ans, c'est de la surface d'attaque gratuite.

Symfony ? Doctrine ? Pour cinq pages sans base de données ? L'autoloader seul pèse plus que l'intégralité du projet. Le composer.json introduit un arbre de dépendances dont chaque nœud est un vecteur d'attaque et une dette de maintenance. Le vrai luxe d'un site en PHP brut, c'est qu'il tourne encore dans 10 ans sans toucher une ligne — pas de breaking changes entre versions majeures d'un framework, pas de deprecation notices, pas de composer update qui casse tout un vendredi.

La stack finale tient en une phrase : index.php + un dossier includes/, Bootstrap 5 et Font Awesome via CDN, PHPMailer vendored pour le formulaire de contact, Apache avec .htaccess pour les rewrites et la sécurité.

Le bilingue sans framework i18n

Le piège classique du bilingue en PHP, c'est de croire qu'il faut gettext, un fichier messages.fr.yml et un système de routing avec paramètre /{_locale}/. Pour cinq pages, la solution la plus maintenable est aussi la plus brutale : dupliquer. index.php pour le français, en/index.php pour l'anglais. Le header est partagé via un include.

Le hreflang qui piège Google

Le hreflang, c'est trois lignes dans le <head> — et 90% des sites les font mal. Les erreurs classiques : oublier x-default, pointer le hreflang vers une URL avec un slash final alors que le canonical n'en a pas, ou ne le mettre que dans une direction (FR pointe vers EN, mais EN ne pointe pas vers FR).

<link rel="alternate" hreflang="fr" href="https://viticulture-solutions.com/" />
<link rel="alternate" hreflang="en" href="https://viticulture-solutions.com/en/" />
<link rel="alternate" hreflang="x-default" href="https://viticulture-solutions.com/" />

Le x-default pointe vers le français — c'est le marché principal. Chaque page des deux langues porte les trois liens. Et dans le sitemap, la même chose avec <xhtml:link> :

<url>
    <loc>https://viticulture-solutions.com/</loc>
    <xhtml:link rel="alternate" hreflang="fr" href="https://viticulture-solutions.com/" />
    <xhtml:link rel="alternate" hreflang="en" href="https://viticulture-solutions.com/en/" />
</url>

Pas d'auto-redirect par langue du navigateur

Décision contre-intuitive : pas de redirection automatique basée sur Accept-Language. Google le déconseille explicitement. Les raisons sont concrètes : le Googlebot crawle depuis les US avec Accept-Language: en — une auto-redirect l'empêche d'indexer la version française. Les CDN et proxies cachent parfois le header. Et les utilisateurs expatriés ont un navigateur en anglais mais veulent lire en français.

Le hreflang + x-default suffit côté SERP. Côté UX, un switcher visible fait le reste.

Le switcher : segmented control, pas bouton ambigu

Un bouton unique "EN" dans la nav, c'est un classique du web — et c'est ambigu. Est-ce que ça affiche la langue courante ou la langue cible ? On clique sur la langue qu'on parle ou celle qu'on veut ? Personne ne sait vraiment.

La solution : un segmented control FR | EN côte à côte. La langue active est highlightée, l'autre est cliquable. Zéro ambiguïté. L'utilisateur voit l'état courant et l'alternative en même temps. Pas de dropdown, pas de drapeaux (les drapeaux représentent des pays, pas des langues — demandez aux Belges, aux Suisses ou aux Canadiens).

Anti-spam : 7 couches, zéro friction

Le formulaire de contact d'un site vitrine est un aimant à spam. La solution paresseuse, c'est reCAPTCHA partout. Résultat : le vrai client qui veut un devis se retrouve à cliquer sur des feux de signalisation pendant 30 secondes. Pire, reCAPTCHA envoie les données de navigation à Google — pour un site européen, c'est un sujet RGPD.

L'approche ici : une cascade de 7 filtres côté serveur. Les 6 premiers sont invisibles pour les humains. Cloudflare Turnstile n'intervient qu'en dernier recours, et seulement s'il est configuré (clé optionnelle).

La cascade

  1. Méthode HTTP — seul POST passe, tout le reste retourne une 405
  2. Origin / Referer — la requête doit venir du domaine du site. Bloque les soumissions cross-origin directes
  3. Honeypot — champ caché en CSS (display: none). Les bots le remplissent systématiquement, les humains ne le voient pas. Rejet silencieux sans feedback
  4. Timestamp — un champ hidden contient le timestamp de chargement du formulaire. Rejet si soumission en moins de 3 secondes (bot) ou plus de 24 heures (replay)
  5. Rate-limit fichier par IP — max 1 envoi par IP toutes les 2 minutes, stocké dans data/. Pas de base de données, juste un fichier texte avec rotation
  6. Blacklist mots + compteur d'URLs — rejet si le message contient des mots blacklistés (viagra, casino...) ou plus de 2 URLs. Les vrais clients n'envoient pas 5 liens dans un formulaire de contact
  7. Turnstile — si la clé est configurée dans le config. Widget invisible ou interactif selon le score de risque Cloudflare
// Couche 4 : timestamp — trop rapide = bot, trop vieux = replay
$elapsed = time() - (int)($_POST['_ts'] ?? 0);
if ($elapsed < 3 || $elapsed > 86400) {
    log_spam('timestamp', $ip);
    redirect_with_error('Délai de soumission invalide.');
}

// Couche 5 : rate-limit fichier
$rate_file = __DIR__ . '/data/rate_' . md5($ip) . '.txt';
if (file_exists($rate_file) && (time() - filemtime($rate_file)) < 120) {
    log_spam('rate-limit', $ip);
    redirect_with_error('Trop de messages. Réessayez dans 2 minutes.');
}
touch($rate_file);

En amont de PHP, Apache bloque les ranges IP de datacenters connus pour les spam-bots via .htaccess. Et tout ce qui est rejeté est loggé dans data/spam.log — format structuré avec timestamp, couche de rejet, IP et extrait du message. En trois semaines de production : 147 tentatives de spam bloquées, zéro faux positif, zéro captcha affiché à un humain.

Sécurité HTTP : le gotcha CSP avec les CDN

Les headers de sécurité HTTP sont le truc que tout le monde sait qu'il faut mettre, et que personne ne teste vraiment. HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy — la liste est connue. Le vrai piège, c'est la Content Security Policy.

# .htaccess — Headers de sécurité
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"

Pour la CSP, le réflexe c'est de whitelister les domaines CDN dans default-src et de passer à la suite. Sauf que Bootstrap charge ses fonts via connect-src, et Font Awesome fait du font-src. Un CDN comme cdnjs.cloudflare.com doit apparaître dans script-src et style-src — pas juste dans default-src. Et jsdelivr.net nécessite aussi connect-src pour le prefetching.

# CSP — chaque directive est explicite
Header always set Content-Security-Policy "\
  default-src 'self'; \
  script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; \
  style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; \
  font-src 'self' https://cdnjs.cloudflare.com; \
  img-src 'self' data:; \
  connect-src 'self' https://cdn.jsdelivr.net; \
  frame-src 'none'; \
  object-src 'none'"

Le test : ouvrir la console du navigateur sur chaque page. Pas de violation CSP = c'est bon. Une violation = quelque chose est bloqué silencieusement, et le site a l'air cassé sans erreur visible côté utilisateur. J'ai découvert le problème connect-src uniquement parce que le prefetch des fonts échouait silencieusement — les fonts se chargeaient au premier rendu au lieu d'être pré-cachées, ce qui ajoutait 200ms au First Contentful Paint.

Performance : les vraies optims, pas les micro-benchmarks

Sur un site vitrine, la performance se joue en 4 endroits : le Largest Contentful Paint, le poids des images, le cache, et le Cumulative Layout Shift. Le reste c'est du bruit.

Le piège du lazy loading sur la première image

Bootstrap 5 carousel en hero. Naturellement, on met loading="lazy" sur toutes les images — c'est la "bonne pratique". Sauf que la première image du carousel est le LCP. Lui mettre loading="lazy" dit au navigateur "charge ça quand tu auras le temps", ce qui retarde le LCP de 500ms à 2 secondes selon la connexion.

<!-- ❌ Anti-pattern : lazy sur la première image visible -->
<img src="slide-1.webp" loading="lazy" alt="...">

<!-- ✅ Première image : eager + priorité haute + preload -->
<img src="slide-1.webp" loading="eager" fetchpriority="high" alt="...">

Et dans le <head>, uniquement sur la homepage :

<?php if ($current_page === 'index.php'): ?>
<link rel="preload" as="image" href="/assets/images/slide-1.webp" type="image/webp">
<?php endif; ?>

Le preload conditionnel évite de charger l'image du carousel sur les pages intérieures. Résultat : LCP passé de 3.2s à 1.4s sur mobile 3G simulé.

WebP et cache-busting sans Webpack

Toutes les images sont converties en WebP via cwebp -q 82. Réduction moyenne : 65% (un slide de 101 Ko en JPEG tombe à 35 Ko en WebP). Le wrapping <picture> avec fallback JPEG pour les vieux navigateurs :

<picture>
    <source srcset="/assets/images/slide-1.webp" type="image/webp">
    <img src="/assets/images/slide-1.jpg" alt="Équipement viticole"
         width="1200" height="600" loading="eager" fetchpriority="high">
</picture>

Le width et height explicites sur chaque image éliminent le CLS — le navigateur réserve l'espace avant le chargement. Pas besoin d'aspect-ratio CSS, les attributs HTML suffisent.

Pour le cache-busting, un helper PHP de 3 lignes remplace Webpack :

function asset(string $path): string {
    return $path . '?v=' . filemtime($_SERVER['DOCUMENT_ROOT'] . '/' . ltrim($path, '/'));
}
// Usage : <link rel="stylesheet" href="<?= asset('/assets/css/style.css') ?>">

Le suffixe ?v= change automatiquement quand le fichier est modifié. Le navigateur invalide son cache sans qu'on touche au nom du fichier. Côté Apache, mod_expires met 1 mois de cache sur les statics.

Bootstrap 5 carousel est un composant qui a l'air simple — jusqu'à ce qu'on veuille des transitions fluides, des cards à hauteur égale, et le respect de prefers-reduced-motion.

prefers-reduced-motion coupe l'autoplay

Par défaut, Bootstrap 5 respecte prefers-reduced-motion: reduce en désactivant l'autoplay du carousel. C'est la bonne chose à faire pour les utilisateurs qui ont activé ce réglage. Mais si le client veut absolument un carousel qui défile automatiquement, il faut surcharger en CSS avec un timing court et une transition douce — pas une animation de slide complète.

Cards à hauteur égale

.row avec display: flex est censé égaliser les hauteurs des colonnes. Ça marche tant que les cards n'ont pas de padding interne complexe ou de contenus de longueurs très différentes. La solution qui tient : un wrapper intermédiaire .brand-card-wrapper avec height: 100% qui enveloppe chaque card.

.brand-card-wrapper {
    height: 100%;
    display: flex;
    flex-direction: column;
}
.brand-card-wrapper .card {
    flex: 1;
}

Animations séquencées : timing court

Des éléments qui apparaissent un par un dans une section "nos marques" ou "nos services". Le réflexe c'est 300ms entre chaque élément. Résultat : avec 4 éléments, le dernier apparaît 1.2 secondes après le premier — ça donne une impression de site lent qui charge. Le bon delta est 120-200ms avec un léger chevauchement des transitions. L'œil perçoit la séquence sans attendre.

CI/CD : la pipeline qui teste tout

Un site en PHP brut sans framework, ça veut dire pas de bin/phpunit intégré, pas de npm test par défaut, pas de linter configuré. Tout est à brancher. La pipeline GitHub Actions tourne à chaque push :

  1. php -l sur tous les fichiers PHP (sauf PHPMailer vendored) — lint de syntaxe
  2. Stylelint + ESLint — cohérence CSS/JS
  3. PHPUnit — tests unitaires sur les helpers anti-spam (honeypot, timestamp, rate-limit). Ces fonctions ont été extraites dans des classes isolées justement pour être testables
  4. Playwright E2E — test du formulaire de contact : honeypot rempli → rejet, timestamp trafiqué → rejet, soumission valide → succès
  5. pa11y-ci — audit accessibilité WCAG 2.1 AA automatisé sur chaque page
  6. Lighthouse CI — score performance + SEO, avec seuils de régression

Le piège du sitemap en CI

Le sitemap passe par une RewriteRule Apache : /sitemap.xml redirige vers sitemap.php. En CI, pas d'Apache — le serveur PHP intégré (php -S) ne lit pas le .htaccess. Tester /sitemap.xml retourne un 404.

Solution : tester directement /sitemap.php en CI. L'URL publique /sitemap.xml reste celle soumise à Google — c'est Apache en production qui fait le routing. Le test CI vérifie que le PHP génère un XML valide, pas que la rewrite fonctionne.

# GitHub Actions — tester le sitemap sans Apache
- name: Test sitemap
  run: |
    curl -s http://localhost:8000/sitemap.php | xmllint --noout -
    # Vérifie que le XML est well-formed, sans dépendre de la RewriteRule

Accessibilité : les détails qui comptent

L'accessibilité d'un site vitrine se joue sur quelques détails qu'on oublie systématiquement quand on copie un template Bootstrap.

  • Skip link — lien caché "Aller au contenu" visible au focus clavier. Premier élément du <body>
  • focus-visible custom — outline vert 3px au lieu du outline bleu par défaut. Assez visible pour être utile, assez sobre pour ne pas casser le design
  • aria-label partout — nav, boutons, switcher de langue. Un lecteur d'écran doit pouvoir naviguer le site sans voir l'écran
  • prefers-reduced-motion dosé — les transitions CSS restent (0.2s ease), les animations décoratives disparaissent. L'utilisateur garde le feedback visuel sans le mouvement gratuit

Le piège palette : la charte graphique du client utilise un vert vignoble foncé et un gold doré. Le vert passe les contraste WCAG AA sur fond blanc sans problème. Le gold, lui, échoue systématiquement sur les petits textes — ratio 3.2:1 au lieu du minimum 4.5:1. Solution : le gold est réservé aux éléments décoratifs (bordures, icônes) et aux textes en font-size: 18px+ (seuil AA large text à 3:1). Les textes courants restent en vert foncé ou en gris anthracite.

SEO : le score et ce qui le compose

Score final audité : 76/100 global (SEO 85, Performance 80, Accessibilité 82, Sécurité 78, Code 70). Ce n'est pas un 100/100. Voici pourquoi, et quels compromis sont délibérés.

Le JSON-LD couvre deux schémas : Organization sur toutes les pages (nom, logo, contact, réseaux sociaux du groupement) et LocalBusiness sur la page contact (adresse, téléphone, horaires). Open Graph et Twitter Card sont complets — avec images optimisées pour chaque réseau.

Le sitemap est un fichier PHP qui lit le filesystem et calcule les lastmod via filemtime(). Pas de sitemap statique à maintenir manuellement — quand un fichier est modifié, le sitemap reflète la date automatiquement.

// sitemap.php — lastmod dynamique
foreach ($pages as $page) {
    $lastmod = date('Y-m-d', filemtime($page['file']));
    echo "<url><loc>{$page['url']}</loc><lastmod>{$lastmod}</lastmod></url>\n";
}

Le score "Code 70" vient principalement de l'absence de minification CSS/JS. Choix délibéré : pas de build step = pas de node_modules en production, pas de npm install sur le serveur mutualisé, pas de pipeline de build à maintenir. Les fichiers font 15 Ko au total — la compression gzip d'Apache réduit le transfer à ~4 Ko. Le gain d'un minifieur serait de l'ordre de 800 octets.

Conclusion

Le vrai enseignement de ce projet, ce n'est pas "PHP brut c'est suffisant" — ça, c'est évident pour quiconque a déjà livré un site de 5 pages. C'est que la complexité qu'on ajoute par réflexe (framework, CMS, build pipeline, système i18n) n'est pas gratuite. Chaque couche d'abstraction est une surface d'attaque, une dette de maintenance, et un vecteur de régression.

Les vrais problèmes intéressants étaient ailleurs : dans l'anti-spam à 7 couches qui ne montre jamais de captcha, dans la CSP qui casse silencieusement les CDN, dans le loading="lazy" qui détruit le LCP, dans le hreflang qui piège Google si on oublie x-default. Des problèmes qui existent avec ou sans framework — mais qui, sans framework, ne sont pas cachés derrière une abstraction. On les voit, on les comprend, on les fixe une fois.

Le repo est public : github.com/ohugonnot/viticulture-solutions.

Commentaires (0)