EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Web / A 26-Page Bilingual Site: Why .php Broke Every Link
Web

A 26-Page Bilingual Site: Why .php Broke Every Link

by Ton-Or ·19 Oct 2026·9 min read
A 26-Page Bilingual Site: Why .php Broke Every Link

A static site with just 26 pages sounds like a small job with little room for things to go wrong, until the day the footer needs updating and I find myself opening and editing files 26 times. The solution was to extract the repeated parts into a shared file, which led the entire site into three more layers of problems, one after another.

I maintain a community tourism website. It is entirely static, with 13 Thai pages and another 13 English pages that must match one to one, for a total of 26 pages. Nothing was complicated at first, until I needed to change the footer text and had to open all 26 files. So I extracted the footer and header into shared files for every page to include, which led straight into three layers of problems. The third layer took the most time.

I have changed all the community names, page names, and domains in this article, but the file structure, rewrite rules, and scripts are the ones actually in use.

Layer One: The Page Displays PHP Tags as Text

I finished writing the includes, and local testing went smoothly. Once I uploaded the site to the production server, I got a page with strange text in the middle of the screen.

$ curl -s https://example.com/index.html | tail -4
<?php $base = ""; include "includes/footer-th.php"; ?>
  </body>
</html>

The PHP code was sent as raw content for the browser to display, which meant the file had never passed through the PHP interpreter. The reason was straightforward: production was running nginx, configured to forward only files with the .php extension to PHP-FPM. Files ending in .html were ordinary files, read and sent as they were.

# nginx — กติกาที่ทำให้ .html ไม่มีวันได้เจอ PHP
location ~ \.php$ {
    include fastcgi_params;
    fastcgi_pass unix:/run/php-fpm.sock;     # เฉพาะ .php เท่านั้นที่มาถึงตรงนี้
}
location / {
    try_files $uri $uri/ =404;               # .html ถูกอ่านแล้วส่งออกไปตรง ๆ
}

It worked locally because my machine ran Apache, where I had inadvertently left an AddHandler setting from a previous job that routed .html through PHP. I would not even recommend this configuration for production, because it forces every static file through the interpreter, wasting resources on pages without a single line of code.

Different web servers in development and production are a ticking time bomb

This entire set of bugs came from one thing: my machine ran Apache, but the destination ran nginx. As a result, everything that depended on web server configuration would pass locally and fail in production: file extension handlers, rewrite rules, and .htaccess, which nginx does not read, not even a single line. If matching the environments is not possible, at least have a checker that requests the real URLs after every deployment.

The correct fix was to change the extension of pages containing includes to .php and make it a rule that new pages are always created as .php, without having to wonder which pages contain code.

Layer Two: Changing Extensions Breaks Links Across the Site

Once I renamed the 26 files, every internal link pointing to .html immediately broke across the site: menus, language switchers, and links in the content. Fixing this by hand was impractical, and I would certainly miss some links. So I wrote a single script that did both jobs together: update the links first, then rename the files.

# tools/rename_to_php.py — แก้ลิงก์ภายในก่อน แล้วค่อยเปลี่ยนนามสกุล ทำทีเดียวทั้ง 26 หน้า
import re
from pathlib import Path

ROOT = Path('site')
PAGES = [
    'index.html', 'sitemap.html', 'plan/one-day-trip.html',
    'stories/temple.html', 'stories/pond.html', 'stories/shrine.html',
    'stories/indigo.html', 'stories/festival.html', 'stories/craft.html',
    'stories/market.html', 'stories/landmark.html', 'stories/legend.html',
    'stories/museum.html',
]
PAGES += ['en/' + p for p in PAGES]          # ฝั่ง EN โครงเดียวกันเป๊ะ

# แก้เฉพาะ href/src ที่เป็นลิงก์ภายใน — ข้าม http, //, mailto:, tel: และ anchor
LINK = re.compile(
    r'((?:href|src)\s*=\s*["\'])(?!https?://|//|mailto:|tel:|#)([^"\']*?)\.html((?:[#?][^"\']*)?["\'])')

changed = 0
for rel in PAGES:
    src = ROOT / rel
    if not src.exists():
        print(f'[WARN] ไม่พบ {rel}')
        continue
    text = src.read_text(encoding='utf-8')
    new = LINK.sub(lambda m: f'{m.group(1)}{m.group(2)}.php{m.group(3)}', text)
    if new != text:
        src.write_text(new, encoding='utf-8')
        changed += 1
    src.rename(src.with_suffix('.php'))
    print(f'  {rel} -> {rel[:-5]}.php')

print(f'[OK] เปลี่ยนชื่อ {len(PAGES)} ไฟล์ · แก้ลิงก์ภายใน {changed} ไฟล์')

The part that needs the most care in this script is the negative lookahead that excludes https?://. Without it, external links that happen to end in .html will also be changed, leaving broken links to other people's sites without me realizing it. These are very hard to find because the failures happen outside my site.

The $base Rules: Eight Cases That Must Be Covered

Once I had includes, the next problem was the links inside the include files themselves. The same footer file was called from pages at different directory depths, so its relative links pointed to different places each time. I fixed this by having the calling page specify how deep it was.

<?php
// หน้าในโฟลเดอร์ stories/ ของฝั่งไทย
// $base = ทางกลับไปที่ "รากของภาษา" ใช้สำหรับลิงก์ข้างในไฟล์ include
// ส่วน path ของ include = ทางกลับไปที่ "รากของเว็บ" ที่โฟลเดอร์ includes/ อยู่
$base = "../";
include "../includes/footer-th.php";
?>
<?php // includes/footer-th.php — ใช้ $base ที่หน้าเรียกตั้งมาให้ ?>
<footer class="site-foot">
  <nav>
    <a href="<?= $base ?>">หน้าแรก</a>
    <a href="<?= $base ?>sitemap.php">แผนผังเว็บ</a>
    <a href="<?= $base ?>plan/one-day-trip.php">แผนเที่ยวหนึ่งวัน</a>
    <a href="<?= $base ?>en/" hreflang="en">English</a>
  </nav>
  <?php include __DIR__ . '/social-icons.php'; ?>
</footer>

These two values are different, and this is where I went wrong the first time. I thought I could use the same value for both, which made all the links on pages in English subdirectories point back to the Thai side. So I put a table of all eight cases at the top of the include file.

ตำแหน่งหน้า                    $base    path ของ include
/index.php                     ""       includes/footer-th.php
/sitemap.php                   ""       includes/footer-th.php
/stories/temple.php            "../"    ../includes/footer-th.php
/plan/one-day-trip.php         "../"    ../includes/footer-th.php
/en/index.php                  ""       ../includes/footer-en.php
/en/sitemap.php                ""       ../includes/footer-en.php
/en/stories/temple.php         "../"    ../../includes/footer-en.php
/en/plan/one-day-trip.php      "../"    ../../includes/footer-en.php

กฎอ่านง่าย ๆ สองข้อ
  $base          นับจากหน้านั้นกลับไปที่รากของ "ภาษา" ตัวเอง (/ หรือ /en/)
  path ของ include  นับจากหน้านั้นกลับไปที่รากของ "เว็บ" ที่ includes/ วางอยู่
ลิงก์ข้ามภาษาในฝั่ง EN ใช้ <?= $base ?>../ คือกลับไปรากภาษาแล้วขึ้นอีกชั้น
Use DIR for nested includes, but base for links

When an include file includes another file, always use __DIR__, because it is that file's actual location on disk, regardless of who called it. Use $base only for links the browser will follow. These are two different coordinate systems. Mix them up and you get broken pages and includes that cannot find their files at the same time.

Layer Three: Clean URLs with a Half-Finished Rewrite and 301 Setup

Once every page was .php, the client pointed out that the extensions in the URLs looked untidy. That was true, and it also affected SEO. So I set up clean URLs on the Apache side of DirectAdmin, which reads .htaccess.

At first, I wrote just one rule: if the requested URL did not correspond to an existing file, try appending .php.

# เวอร์ชันแรก — ทำครึ่งเดียว
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.*)$ $1.php [L]

The result was that /stories/temple worked, but menu links across the site still pointed to /stories/temple.php. Every page was now accessible in two ways, which search engines saw as duplicate content. So I added a 301 rule, and the site immediately went down.

# เวอร์ชันที่ทำเว็บตาย — วนไม่รู้จบ
RewriteRule ^(.*)\.php$ /$1 [R=301,L]
$ curl -sI https://example.com/stories/temple | head -3
HTTP/2 500
# ใน error log: Request exceeded the limit of 10 internal redirects

The cause was that the top rule converted /stories/temple into /stories/temple.php internally, then Apache ran that result through the rules again. The 301 rule saw the .php ending and redirected back to /stories/temple, repeating until it hit the limit. The fix was for the 301 rule to check what the user actually typed, rather than what the server had already transformed. The variable that preserves the original request is THE_REQUEST.

Why the first rewrite and 301 rules loopedUser requests/stories/templeAppend .phpInternal rewriteFirst 301 ruleSees .php, redirectsLoops until the limit of 10, then HTTP 500Fix: THE_REQUEST checks user input, not internal rewrites
Apache runs the result of the rule that appends .php through the rules again. The first 301 rule therefore sees the .php added by the server itself and redirects back to the clean URL, over and over.
# .htaccess ตัวที่ใช้จริง
AddDefaultCharset UTF-8
DirectoryIndex index.php index.html

<IfModule mod_rewrite.c>
  RewriteEngine on

  # 1) เด้ง .php ที่ผู้ใช้พิมพ์มาเอง ให้ไปที่ URL สะอาด (301 ครั้งเดียว)
  #    THE_REQUEST = บรรทัดคำขอดิบ เช่น "GET /stories/temple.php HTTP/1.1"
  #    ตัวแปรนี้ไม่เปลี่ยนตามการ rewrite ภายใน เลยไม่วน
  RewriteCond %{THE_REQUEST} \s/+(.+?)\.php[\s?] [NC]
  RewriteRule ^ /%1 [R=301,L]

  # 2) URL สะอาดที่ไม่มีไฟล์จริง ให้ลองเติม .php แล้วเสิร์ฟภายใน (ไม่เด้ง)
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteCond %{REQUEST_FILENAME}.php -f
  RewriteRule ^(.*)$ $1.php [L]
</IfModule>

<IfModule mod_headers.c>
  Header always set X-Content-Type-Options "nosniff"
  Header always set X-Frame-Options "SAMEORIGIN"
  Header always set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>

ErrorDocument 404 /404.php
Rule order matters more than the rules themselves

The 301 redirect rule must always come before the rule that appends the extension. If the order is reversed, a clean request gets .php appended first, then encounters the 301 rule ready to redirect it back. Even with THE_REQUEST used correctly, the behavior can still be strange. And remember that nginx does not read this file at all. If the destination runs nginx, put try_files $uri $uri.php $uri/ =404; in the server block instead.

After fixing this, I needed to check all three routes, not just whether the page opened.

# 1) URL สะอาด ต้องได้ 200 ตรง ๆ ไม่เด้ง
$ curl -sI https://example.com/stories/temple | head -1
HTTP/2 200

# 2) URL เก่าที่มี .php ต้องเด้ง 301 ไปที่สะอาด "ครั้งเดียว"
$ curl -sI https://example.com/stories/temple.php | grep -E '^(HTTP|location)'
HTTP/2 301
location: /stories/temple

# 3) ตามไปจนสุดทาง ต้องเห็นการเด้งแค่หนึ่งครั้ง แล้วจบที่ 200
$ curl -sIL https://example.com/en/stories/temple.php | grep -c '^HTTP'
2

The Remaining Trap: Internal Links and Canonical Tags

Fixing the rules was not the end of it, because menu links across the site still used .php. Every menu click wasted a request on a 301 redirect, and the canonical tags I had added to every page still declared their URLs as .php, contradicting what the server said. This meant another pass through all 26 pages.

# tools/clean_links.py — ถอด .php ออกจากลิงก์ภายในและ canonical ทั้งเว็บ
import re
from pathlib import Path

ROOT = Path('site')
LINK = re.compile(r'((?:href|src)\s*=\s*["\'])(?!https?://|//|mailto:|tel:|#)([^"\']*?)\.php((?:[#?][^"\']*)?["\'])')
CANON = re.compile(r'(<link[^>]+rel=["\']canonical["\'][^>]+href=["\'][^"\']*?)\.php(["\'])')

n = 0
for p in sorted(ROOT.rglob('*.php')):
    if 'includes' in p.parts:
        continue                                  # ไฟล์ include ใช้ $base อยู่แล้ว อย่าไปแตะ
    t = p.read_text(encoding='utf-8')
    new = CANON.sub(r'\1\2', LINK.sub(lambda m: f'{m.group(1)}{m.group(2)}{m.group(3)}', t))
    # index.php ของแต่ละภาษาให้เหลือเป็น "./" กับ "../" ไม่ใช่ "index" โดด ๆ
    new = new.replace('href="index"', 'href="./"').replace('href="../index"', 'href="../"')
    if new != t:
        p.write_text(new, encoding='utf-8')
        n += 1
        print(f'  แก้ {p.relative_to(ROOT)}')

print(f'[OK] แก้ลิงก์ภายใน {n} ไฟล์')
Before updating the links

Works, but wastes a request on every click

  • Every menu link goes through one 301 redirect
  • Canonical tags point to URLs I do not want used
  • Search engines see two addresses per page
  • Page statistics split into two entries
After updating the links

One route for people and bots

  • Menu clicks reach the destination immediately
  • Canonical tags match what the server says
  • Only one address remains per page
  • 301 redirects remain only for old external links

A Checker to Keep TH and EN Matched One to One

A bilingual site edited with scripts like these has a particular risk: one language gets updated while the other is missed, and nobody notices until a foreign visitor comes across it. So I wrote a comparison checker to verify that more than 40 important elements were still present on both sides.

# tools/compare_th_en.py — เทียบโครงสร้าง TH กับ EN ทีละจุด
from pathlib import Path

ROOT = Path('site')
MARKERS = [
    ('โลโก้',              'logo-site.png'),
    ('แบนเนอร์หลัก',        'hero-slide'),
    ('จุดวนแบนเนอร์',       'hero-slide-dots'),
    ('ไอคอนโซเชียล',        'social-icons.php'),
    ('ส่วนแนะนำชุมชน',      'id="about"'),
    ('ส่วนเรื่องเล่า',       'id="story"'),
    ('ส่วนสถานที่',         'id="attractions"'),
    ('การ์ดสถานที่ 9 ใบ',   'lg:grid-cols-3'),
    ('ส่วนแผนเที่ยว',       'id="itinerary"'),
    ('จุดเวลา 09:00',      '09:00'),
    ('จุดเวลา 15:15',      '15:15'),
    ('ส่วนของฝาก',          'id="shop"'),
    ('ส่วนติดต่อ',          'id="contact"'),
    ('ลิงก์แผนผังเว็บ',      'sitemap.php'),
    ('ลิงก์สลับภาษา',       'hreflang='),
    ('แท็ก canonical',     'rel="canonical"'),
]

PAIRS = [('index.php', 'en/index.php'),
         ('sitemap.php', 'en/sitemap.php'),
         ('plan/one-day-trip.php', 'en/plan/one-day-trip.php')]

bad = 0
for th_rel, en_rel in PAIRS:
    th = (ROOT / th_rel).read_text(encoding='utf-8')
    en = (ROOT / en_rel).read_text(encoding='utf-8')
    print(f'--- {th_rel}  vs  {en_rel}')
    for label, mark in MARKERS:
        a, b = th.count(mark), en.count(mark)
        if a != b:
            print(f'  [DIFF] {label}: TH={a} EN={b}  (marker: {mark})')
            bad += 1
    # ฝั่ง EN ต้องไม่หลงเหลือ include ของฝั่งไทย
    if 'footer-th.php' in en:
        print('  [ERR] ฝั่ง EN ยัง include footer ของไทยอยู่')
        bad += 1

print(f'\n[{"FAIL" if bad else "OK"}] จุดที่ไม่ตรงกัน {bad} จุด')

There is nothing clever about this checker. It just counts how many times each marker appears on each side and compares the counts. But it has caught every instance where I edited the Thai side and forgot the English side, which happens far more often than I expected, especially when the client asks for an urgent addition.

Checking for Broken Links Across the Site Before Calling It Done

The final check is to go through every link on every page, send a real request to the destination URL, and see whether it returns 200. Crucially, it also checks how many redirects occur before reaching the destination.

# tools/check_links.py — ไล่ทุกลิงก์ภายใน ต้อง 200 และต้องไม่เด้งเกินหนึ่งครั้ง
import re, sys, urllib.parse, urllib.request
from pathlib import Path

BASE = 'https://example.com'
ROOT = Path('site')
HREF = re.compile(r'href=["\'](?!https?://|//|mailto:|tel:|#)([^"\']+)["\']')

seen, bad = set(), 0
for p in sorted(ROOT.rglob('*.php')):
    if 'includes' in p.parts:
        continue
    page_dir = '/' + p.relative_to(ROOT).parent.as_posix().strip('.').strip('/')
    for href in HREF.findall(p.read_text(encoding='utf-8')):
        url = urllib.parse.urljoin(BASE + page_dir.rstrip('/') + '/', href.split('#')[0])
        if not url or url in seen:
            continue
        seen.add(url)
        req = urllib.request.Request(url, method='HEAD',
                                     headers={'User-Agent': 'link-check/1.0'})
        try:
            with urllib.request.urlopen(req, timeout=10) as r:
                code = r.status
                final = r.geturl()          # urlopen ตามการเด้งให้เอง ถ้าปลายทางไม่ตรงกับที่ขอ = มีเด้ง
        except Exception as e:
            print(f'[ERR] {url}  <- {p.name}  ({e})')
            bad += 1
            continue
        if code != 200:
            print(f'[ERR] {code} {url}  <- {p.name}')
            bad += 1
        elif final != url:
            print(f'[WARN] เด้ง {url} -> {final}  <- {p.name}  (ควรแก้ลิงก์ต้นทาง)')
            bad += 1

print(f'\n[{"FAIL" if bad else "OK"}] ตรวจ {len(seen)} ลิงก์ · ปัญหา {bad}')
sys.exit(1 if bad else 0)

The lines warning about redirects are the most valuable, because links that redirect still work normally, and nobody reports them as broken. But they are evidence that I have not finished updating everything. Leave them long enough, and those links will get copied elsewhere, becoming debt that requires keeping the 301 redirects forever.

The Actual Numbers and Work Sequence

26 pagesTotal pages in both languages
3 filesParts extracted for shared use
8 casesBase values that must be correct
2 rulesMain lines in htaccess
10Apache's redirect loop limit
1 redirectAcceptable redirect count
16 markersUsed to compare both languages
4 scriptsKept for reuse
The full work sequenceExtract shared includes3 files->Change to .phpUpdate links too->Define base rules8 cases->Clean URLs301 before rewrite->Update menu linksReduce redirects to 0->Compare languagesThen check links
  1. Check which web server production uses, then match the development environment to it. Do not rely on unusual handlers that exist only on your own machine.
  2. Pages with includes must use .php, and make it a rule to always create new pages as .php from the start.
  3. Change extensions with a script that updates internal links at the same time, and use a lookahead to exclude external links.
  4. Put a table covering every $base case at the top of the include file. Do not rely on memory.
  5. In .htaccess, always place the 301 rule that reads THE_REQUEST before the rule that appends the extension.
  6. Remove .php from internal links and canonical tags across the site, leaving zero redirects for visitors following the menus.
  7. Run the bilingual comparison checker and the link checker after every deployment, not just major ones.

My lesson from this job is that a static site is not always simpler than a site with a backend. It just moves the complexity into web server configuration and disciplined file editing. Nobody writes tests for those two things on my behalf; I have to write them myself. What looked like a job of changing the extensions of 26 files ended up touching the web server, the link structure, and SEO. Anyone using this .htaccess can adjust the folder names and 404 page to suit their own project. If I have missed anything in the explanation or made any typos, I will come back and fix them later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first