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
- Méthode HTTP — seul POST passe, tout le reste retourne une 405
- Origin / Referer — la requête doit venir du domaine du site. Bloque les soumissions cross-origin directes
- Honeypot — champ caché en CSS (
display: none). Les bots le remplissent systématiquement, les humains ne le voient pas. Rejet silencieux sans feedback - 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)
- 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 - 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
- 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.
Carousel Bootstrap 5 : trois gotchas
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 :
- php -l sur tous les fichiers PHP (sauf PHPMailer vendored) — lint de syntaxe
- Stylelint + ESLint — cohérence CSS/JS
- 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
- Playwright E2E — test du formulaire de contact : honeypot rempli → rejet, timestamp trafiqué → rejet, soumission valide → succès
- pa11y-ci — audit accessibilité WCAG 2.1 AA automatisé sur chaque page
- 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.