Building a Brochure Site in Raw PHP: Bilingual, Anti-Spam, CI/CD — No Framework

Four vineyard equipment manufacturers. An aging brochure website. The brief: "we want something clean, bilingual, mobile-friendly, and that doesn't get spammed." No customer portal, no e-commerce, no back-office. Five pages. The natural reaction in 2026 is to reach for WordPress, Symfony or Next.js. I chose raw PHP 8, Bootstrap 5 via CDN, and zero npm dependencies in production. 25 commits and three weeks later, the site runs on shared hosting at InterServer without asking anyone for anything.

This isn't an article about "why PHP is great." It's a walkthrough of the real problems you hit when building a serious brochure site without a framework: bilingual routing that trips up Google, anti-spam that doesn't harass humans, CSP that breaks CDNs, LCP that tanks because of a misplaced loading="lazy", and CI/CD that tries to test a sitemap without Apache.

Why not WordPress, why not Symfony

The question always comes up. Five static pages, one contact form, no dynamic content. What does a CMS bring here? An admin panel nobody will use, a plugin ecosystem where 40% have published CVEs, and monthly updates that break the theme. WordPress accounts for over 7,000 referenced CVEs. For a site that won't change for 3 years, that's free attack surface.

Symfony? Doctrine? For five pages with no database? The autoloader alone weighs more than the entire project. The composer.json introduces a dependency tree where every node is an attack vector and a maintenance liability. The real luxury of a raw PHP site is that it still runs 10 years from now without touching a line — no breaking changes between major framework versions, no deprecation notices, no composer update breaking things on a Friday.

The final stack fits in one sentence: index.php + an includes/ folder, Bootstrap 5 and Font Awesome via CDN, vendored PHPMailer for the contact form, Apache with .htaccess for rewrites and security.

Bilingual without an i18n framework

The classic trap with bilingual PHP is thinking you need gettext, a messages.fr.yml file and a routing system with a /{_locale}/ parameter. For five pages, the most maintainable solution is also the most brutal: duplicate. index.php for French, en/index.php for English. The header is shared via an include.

The hreflang trap

hreflang is three lines in the <head> — and 90% of sites get them wrong. Classic mistakes: forgetting x-default, pointing the hreflang to a URL with a trailing slash when the canonical doesn't have one, or only adding it in one direction (FR points to EN, but EN doesn't point back to 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/" />

x-default points to French — that's the primary market. Every page in both languages carries all three links. And in the sitemap, the same thing with <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>

No auto-redirect by browser language

Counter-intuitive decision: no automatic redirect based on Accept-Language. Google explicitly advises against it. The reasons are concrete: Googlebot crawls from the US with Accept-Language: en — an auto-redirect prevents it from indexing the French version. CDNs and proxies sometimes strip the header. And expats have browsers set to English but want to read in French.

hreflang + x-default handles SERP. A visible language switcher handles UX.

The switcher: segmented control, not an ambiguous button

A single "EN" button in the nav is a web classic — and it's ambiguous. Does it show the current language or the target? Do you click the language you speak or the one you want? Nobody really knows.

The solution: a segmented control FR | EN side by side. The active language is highlighted, the other is clickable. Zero ambiguity. The user sees the current state and the alternative at the same time. No dropdown, no flags (flags represent countries, not languages — ask Belgians, Swiss, or Canadians).

Anti-spam: 7 layers, zero friction

A brochure site's contact form is a spam magnet. The lazy solution is reCAPTCHA everywhere. Result: the real customer who wants a quote ends up clicking traffic lights for 30 seconds. Worse, reCAPTCHA sends browsing data to Google — for a European site, that's a GDPR concern.

The approach here: a cascade of 7 server-side filters. The first 6 are invisible to humans. Cloudflare Turnstile only kicks in as a last resort, and only if configured (optional key).

The cascade

  1. HTTP method — only POST goes through, everything else returns 405
  2. Origin / Referer — the request must come from the site's domain. Blocks direct cross-origin submissions
  3. Honeypot — hidden field via CSS (display: none). Bots fill it systematically, humans never see it. Silent rejection with no feedback
  4. Timestamp — a hidden field contains the form load timestamp. Rejection if submitted in under 3 seconds (bot) or over 24 hours (replay)
  5. File-based rate-limit by IP — max 1 submission per IP every 2 minutes, stored in data/. No database, just a text file with rotation
  6. Word blacklist + URL counter — rejection if the message contains blacklisted words (viagra, casino...) or more than 2 URLs. Real customers don't send 5 links in a contact form
  7. Turnstile — if the key is configured. Invisible or interactive widget depending on Cloudflare's risk score
// Layer 4: timestamp — too fast = bot, too old = replay
$elapsed = time() - (int)($_POST['_ts'] ?? 0);
if ($elapsed < 3 || $elapsed > 86400) {
    log_spam('timestamp', $ip);
    redirect_with_error('Invalid submission delay.');
}

// Layer 5: file-based rate-limit
$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('Too many messages. Try again in 2 minutes.');
}
touch($rate_file);

Upstream from PHP, Apache blocks known datacenter IP ranges used by spam-bots via .htaccess. And everything that gets rejected is logged in data/spam.log — structured format with timestamp, rejection layer, IP and message excerpt. In three weeks of production: 147 spam attempts blocked, zero false positives, zero captchas shown to a human.

HTTP security: the CSP gotcha with CDNs

HTTP security headers are the thing everyone knows they should set, and nobody actually tests. HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy — the list is well known. The real trap is the Content Security Policy.

# .htaccess — Security headers
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=()"

For CSP, the reflex is to whitelist CDN domains in default-src and move on. Except Bootstrap loads its fonts via connect-src, and Font Awesome uses font-src. A CDN like cdnjs.cloudflare.com must appear in script-src and style-src — not just in default-src. And jsdelivr.net also needs connect-src for prefetching.

# CSP — each directive is explicit
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'"

The test: open the browser console on every page. No CSP violation = good. A violation = something is silently blocked, and the site looks broken with no visible error for the user. I discovered the connect-src issue only because font prefetching was failing silently — fonts loaded at first render instead of being pre-cached, adding 200ms to First Contentful Paint.

Performance: the real optimizations, not micro-benchmarks

On a brochure site, performance comes down to 4 things: Largest Contentful Paint, image weight, caching, and Cumulative Layout Shift. Everything else is noise.

The lazy loading trap on the first image

Bootstrap 5 carousel as hero. Naturally, you put loading="lazy" on all images — that's the "best practice." Except the first carousel image is the LCP. Setting loading="lazy" tells the browser "load this when you get around to it," delaying LCP by 500ms to 2 seconds depending on the connection.

<!-- ❌ Anti-pattern: lazy on the first visible image -->
<img src="slide-1.webp" loading="lazy" alt="...">

<!-- ✅ First image: eager + high priority + preload -->
<img src="slide-1.webp" loading="eager" fetchpriority="high" alt="...">

And in the <head>, only on the homepage:

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

The conditional preload avoids loading the carousel image on inner pages. Result: LCP went from 3.2s to 1.4s on simulated mobile 3G.

WebP and cache-busting without Webpack

All images are converted to WebP via cwebp -q 82. Average reduction: 65% (a 101 KB JPEG slide drops to 35 KB in WebP). The <picture> wrapper with JPEG fallback for older browsers:

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

Explicit width and height on every image eliminate CLS — the browser reserves space before loading. No need for aspect-ratio CSS, the HTML attributes are enough.

For cache-busting, a 3-line PHP helper replaces 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') ?>">

The ?v= suffix changes automatically when the file is modified. The browser invalidates its cache without renaming the file. On the Apache side, mod_expires sets 1-month cache on statics.

Bootstrap 5 carousel is a component that looks simple — until you want smooth transitions, equal-height cards, and prefers-reduced-motion compliance.

prefers-reduced-motion kills autoplay

By default, Bootstrap 5 respects prefers-reduced-motion: reduce by disabling carousel autoplay. That's the right thing to do for users who enabled this setting. But if the client absolutely wants an auto-scrolling carousel, you need to override in CSS with a short timing and smooth transition — not a full slide animation.

Equal-height cards

.row with display: flex is supposed to equalize column heights. It works as long as cards don't have complex internal padding or wildly different content lengths. The solution that holds: an intermediate .brand-card-wrapper with height: 100% wrapping each card.

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

Sequenced animations: keep timing short

Elements appearing one by one in a "our brands" or "our services" section. The reflex is 300ms between each. Result: with 4 elements, the last one shows up 1.2 seconds after the first — it feels like a slow site loading. The right delta is 120-200ms with slight transition overlap. The eye perceives the sequence without waiting.

CI/CD: the pipeline that tests everything

A raw PHP site without a framework means no built-in bin/phpunit, no default npm test, no pre-configured linter. Everything needs wiring. The GitHub Actions pipeline runs on every push:

  1. php -l on all PHP files (except vendored PHPMailer) — syntax lint
  2. Stylelint + ESLint — CSS/JS consistency
  3. PHPUnit — unit tests on anti-spam helpers (honeypot, timestamp, rate-limit). These functions were extracted into isolated classes specifically to be testable
  4. Playwright E2E — contact form testing: filled honeypot → rejection, tampered timestamp → rejection, valid submission → success
  5. pa11y-ci — automated WCAG 2.1 AA accessibility audit on every page
  6. Lighthouse CI — performance + SEO score, with regression thresholds

The sitemap trap in CI

The sitemap goes through an Apache RewriteRule: /sitemap.xml redirects to sitemap.php. In CI, there's no Apache — PHP's built-in server (php -S) doesn't read .htaccess. Testing /sitemap.xml returns a 404.

Solution: test /sitemap.php directly in CI. The public URL /sitemap.xml remains the one submitted to Google — Apache in production handles the routing. The CI test verifies that the PHP generates valid XML, not that the rewrite works.

# GitHub Actions — testing the sitemap without Apache
- name: Test sitemap
  run: |
    curl -s http://localhost:8000/sitemap.php | xmllint --noout -
    # Verifies XML is well-formed, without depending on RewriteRule

Accessibility: the details that matter

A brochure site's accessibility comes down to a few details that are systematically forgotten when copying a Bootstrap template.

  • Skip link — hidden "Skip to content" link visible on keyboard focus. First element in <body>
  • Custom focus-visible — 3px green outline instead of the default blue. Visible enough to be useful, subtle enough not to break the design
  • aria-label everywhere — nav, buttons, language switcher. A screen reader must be able to navigate the site without seeing the screen
  • Measured prefers-reduced-motion — CSS transitions stay (0.2s ease), decorative animations go. The user keeps visual feedback without gratuitous motion

The palette trap: the client's brand guidelines use a dark vineyard green and a golden accent. The green passes WCAG AA contrast on white backgrounds easily. The gold consistently fails on small text — 3.2:1 ratio instead of the 4.5:1 minimum. Solution: gold is reserved for decorative elements (borders, icons) and text at font-size: 18px+ (AA large text threshold at 3:1). Body text stays in dark green or anthracite gray.

SEO: the score and what's behind it

Final audited score: 76/100 overall (SEO 85, Performance 80, Accessibility 82, Security 78, Code 70). It's not a perfect 100. Here's why, and which trade-offs are deliberate.

JSON-LD covers two schemas: Organization on all pages (name, logo, contact, social media) and LocalBusiness on the contact page (address, phone, hours). Open Graph and Twitter Card are complete — with optimized images for each network.

The sitemap is a PHP file that reads the filesystem and calculates lastmod via filemtime(). No static sitemap to maintain manually — when a file is modified, the sitemap reflects the date automatically.

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

The "Code 70" score mainly comes from the lack of CSS/JS minification. Deliberate choice: no build step = no node_modules in production, no npm install on shared hosting, no build pipeline to maintain. The files total 15 KB — Apache gzip compression reduces the transfer to ~4 KB. A minifier would save roughly 800 bytes.

Conclusion

The real lesson from this project isn't "raw PHP is enough" — that's obvious to anyone who's ever shipped a 5-page site. It's that the complexity we add by reflex (framework, CMS, build pipeline, i18n system) isn't free. Every abstraction layer is an attack surface, a maintenance liability, and a regression vector.

The interesting problems were elsewhere: in the 7-layer anti-spam that never shows a captcha, in the CSP that silently breaks CDNs, in the loading="lazy" that tanks LCP, in the hreflang that trips up Google when you forget x-default. Problems that exist with or without a framework — but without one, they aren't hidden behind an abstraction. You see them, you understand them, you fix them once.

The repo is public: github.com/ohugonnot/viticulture-solutions.

Comments (0)