Renaming 1,707 Images in Thai for SEO Without Breaking Links
Image filenames are text that search engines read, and Thai filenames work on the modern web. The problem is the renaming process, which has to update files on disk, database columns, and exported files used by the frontend, all at once.
1-minute summary
- Symptom / problem
- The whole site gallery uses random-code file names that search engines cannot read
- Who should read
- Site owners with many images who want meaningful Thai file names
- Fix in short
- Rename the 1,707 files and update every reference in a single transaction, and watch the trap where curl with a raw Thai path returns 400
I was looking at the search report for a place directory I maintain and noticed that image search traffic was almost zero, even though the system had over a thousand images. Opening the files made it clear why: every image had a long random filename assigned by the uploader during the initial import. That meant there was not a single clue to help search engines work out what each image showed. The job was to give all 1,707 files meaningful Thai names without breaking a single link.
Do Thai Filenames Actually Work?
The answer is yes, and they have for a long time. Every browser automatically converts non-ASCII characters in paths to the standard percent-encoded form before sending them. The web server can decode them back to the actual filenames on disk normally, as long as the filesystem stores names in UTF-8.
What needs care is not Thai itself, but other characters that come with place names. Parentheses, slashes, periods, and question marks can break paths or be rejected by some filesystems. I wrote a single name conversion function and required every part of the system to use it.
function thaiSlug(string $name, int $cap = 45): string
{
$s = preg_replace('/[()\[\]\/\\\\:*?"<>|.,]/u', '', $name); // อักขระที่ทำพาธพัง
$s = preg_replace('/\s+/u', '-', trim($s)); // ช่องว่างเป็นขีด
$s = preg_replace('/-+/u', '-', $s); // ขีดซ้อนเหลือตัวเดียว
$s = trim($s, '-');
return mb_substr($s, 0, $cap, 'UTF-8'); // ตัดด้วย mb_ เท่านั้น
}
// "ศูนย์ตัวอย่าง (สาขาที่ 2)" -> "ศูนย์ตัวอย่าง-สาขาที่-2"
One Thai character takes three bytes in UTF-8. Truncating with a function that counts bytes can leave a partial character at the end of the name. The result is an unreadable filename that cannot be opened. I set the limit at 45 characters because longer names start risking the total path length limit when folders are nested deeply.
Filenames Are Referenced in More Places Than Expected
This is what made the job more than just running a rename command, because the same filename was stored in three places in my system.
The easiest one to forget is the JSON column that stores the entire gallery's image list in a single field. Renaming only the files on disk would immediately break images across the whole site.
-- ดูก่อนว่าชื่อไฟล์ไปโผล่อยู่ที่ไหนบ้าง
SELECT id, img, JSON_LENGTH(gallery) AS n_gallery
FROM places
WHERE img LIKE '%\_%' OR JSON_SEARCH(gallery, 'one', '%.jpg') IS NOT NULL
LIMIT 20;
Rename, Update, and Export in One Transaction
My rule was if any step fails, the entire batch must be reversible, rather than leaving the files half renamed. My script therefore built the complete rename plan first, checked for duplicate names, and only then started making changes.
$plan = []; // เก็บแผนทั้งหมดก่อน ยังไม่แตะไฟล์
foreach ($places as $p) {
$base = thaiSlug($p->name_th);
foreach (json_decode($p->gallery, true) as $i => $old) {
$ext = pathinfo($old, PATHINFO_EXTENSION);
$new = sprintf('%s-%02d.%s', $base, $i, $ext);
$plan[] = ['id' => $p->id, 'idx' => $i, 'old' => $old, 'new' => $new];
}
}
$dupes = array_diff_assoc($n = array_column($plan, 'new'), array_unique($n));
if ($dupes) { fwrite(STDERR, "ชื่อซ้ำ: " . implode(', ', $dupes) . PHP_EOL); exit(1); }
DB::transaction(function () use ($plan, $root) {
$renamed = [];
try {
foreach ($plan as $row) {
$from = "$root/{$row['id']}/{$row['old']}";
$to = "$root/{$row['id']}/{$row['new']}";
if (!@rename($from, $to)) {
throw new RuntimeException("rename ล้มที่ {$row['old']}");
}
$renamed[] = [$to, $from]; // เก็บไว้ย้อนกลับ
}
foreach (group_by_id($plan) as $id => $rows) {
Place::where('id', $id)->update([
'gallery' => json_encode(array_column($rows, 'new'), JSON_UNESCAPED_UNICODE),
'img' => $rows[0]['new'],
]);
}
Publisher::export(); // สร้างไฟล์ที่หน้าเว็บโหลดใหม่
} catch (Throwable $e) {
foreach (array_reverse($renamed) as [$to, $from]) { @rename($to, $from); }
throw $e; // DB ย้อนเอง ไฟล์ย้อนด้วยมือข้างบน
}
});
The catch is that database transactions do not cover file renames on disk. I had to keep my own list of completed renames and manually reverse them in catch before throwing the exception so the database could roll back its own changes.
This script touches actual files in docroot, so I made dry-run mode the default. It requires a confirmation flag before making changes. Before the first live run, back up both the image folder and the relevant column data. Do not trust your own rollback code to handle every case.
The Trap That Cost Me a Day
Once I finished, I tested from the command line out of habit, requested an image, and got a 400 response. Every other image I tried also returned 400. I immediately concluded that Thai filenames did not work on this web server and started planning to revert everything to English names.
curl -I 'https://example.com/img/ศูนย์ตัวอย่าง-01.jpg'
# HTTP/1.1 400 Bad Request
The truth was that every image opened normally in a browser. I found that out when I opened the site myself several hours later. The cause was that curl sent raw UTF-8 bytes in the request line exactly as I typed them, while the browser converted them to percent-encoded form before sending. The web server rejected the former because the request line was malformed, not because it could not find the file.
Raw bytes return 400
- What you type is sent exactly as it is
- The web server rejects the request syntax
- It does not mean the file is missing or the name is invalid
Automatic encoding returns 200
- Converts non-ASCII characters to percent-encoded form before sending
- Matches the behavior of actual users and search bots
- Determines whether the link actually works
To test from the command line, the path must be encoded first.
P=$(python -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" 'ศูนย์ตัวอย่าง-01.jpg')
curl -I "https://example.com/img/$P"
# HTTP/1.1 200 OK
Test with Something That Behaves Like a Real User
The lesson from the previous section was that the checking tool must behave like whatever will actually load the images. I stopped using command-line requests as the deciding test and wrote a script that drove a real browser through every page with a gallery, collecting images that failed to load.
from playwright.sync_api import sync_playwright
bad = []
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page()
pg.on('response', lambda r: bad.append((r.status, r.url))
if r.request.resource_type == 'image' and r.status >= 400 else None)
for pid in place_ids:
pg.goto(f'https://example.com/place/{pid}', wait_until='networkidle')
b.close()
print(f'รูปที่โหลดไม่ผ่าน: {len(bad)}')
for st, u in bad[:20]:
print(st, u)
The first run returned zero entries, confirming that Thai filenames had been working fine from the start. I had wasted time trusting a tool that did not reproduce the actual behavior.
A Related Task: Thai Numerals in Text
While going through this dataset, I found a lot of Thai numerals mixed into text fields, including street numbers, years, and contact numbers. The problem was that people searched using Arabic numerals, and text search did not match across numeral sets.
function th2ar(string $s): string
{
return strtr($s, ['๐'=>'0','๑'=>'1','๒'=>'2','๓'=>'3','๔'=>'4',
'๕'=>'5','๖'=>'6','๗'=>'7','๘'=>'8','๙'=>'9']);
}
I converted them when exporting data to the frontend, without overwriting the source data, because Thai numerals were part of some proper names that should be left alone. And of course, numerals embedded in images could not be converted anyway, so those had to stay as they were.
Checklist Before Running on Production
- Back up the entire image folder and dump the columns that store filenames first.
- Build the complete rename plan before touching any files, and always pass the duplicate-name check first.
- Check the total path lengths after renaming to ensure they stay within the target filesystem's limits.
- Make file renaming, database updates, and file exports for the frontend a single reversible batch.
- Clear all frontend and CDN caches, or images may appear broken even when everything is correct on the server.
- Verify the results with a scripted real browser, not a command that sends raw bytes.
- Submit a new image sitemap, then wait two or three weeks before reviewing the search report and drawing conclusions.
In summary, Thai filenames work and are worth using. The hard part is not the language itself, but tracking down every place that references a filename and updating them all together in a reversible way. I covered checking for corrupted Thai text after a data migration in a separate article, Auditing Thai Text After a Data Migration. That article did not cover filenames or URL encoding at all. Feel free to adapt this for your own system. If I have left anything unexplained or encounter a case the rollback cannot handle, I will come back and update this article.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first