Building a Multi-Language Instagram Downloader in PHP: Lessons from 21 Locales
I recently shipped snapinsta.pl — a small Instagram media downloader — in 21 languages, on shared LiteSpeed hosting behind Cloudflare, running on PHP 8.3 with no framework. Zero Composer dependencies. One index.php front
I recently shipped snapinsta.pl — a small Instagram media downloader — in 21 languages, on shared LiteSpeed hosting behind Cloudflare, running on PHP 8.3 with no framework. Zero Composer dependencies. One index.php front controller, a thin process layer, a signed-download proxy, and a bunch of flat .php content files.
A few decisions that I kept bumping into, and what I'd do the same way again.
1. A front controller, not a router library
The whole site is routed by one file: index.php. It reads $_SERVER['REQUEST_URI'], peels off an optional two-letter language prefix (/pl/reels, /es/story, etc.), looks the remaining slug up in a map, and includes the matching content file.
// index.php — simplified
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$segments = array_values(array_filter(explode('/', $path)));
$lang = 'en';
if (isset($segments[0]) && isset(LANGS[$segments[0]])) {
$lang = array_shift($segments);
}
$slug = $segments[0] ?? 'home';
$page = TOOL_PAGES[$slug] ?? STATIC_PAGES[$slug] ?? null;
if ($page === null) {
http_response_code(404);
include __DIR__ . '/content/404.php';
exit;
}
include __DIR__ . '/content/' . $page . '.php';
That's it. For a site whose routes are known at write-time, pulling in a routing library is pure ceremony. The map lives in config.php next to the language list. Adding a new language is one line; adding a new tool page is two.
The .htaccess rewrites everything that isn't a real file into index.php:
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [L]
The !-f matters — it's what lets ads.txt, robots.txt, and Google Search Console's verification HTML get served as actual files instead of being swallowed by the router.
2. Content files as plain PHP arrays
Every blog post and every tool page is a PHP file that returns an array:
<?php
return [
'meta_title' => 'How to Download Instagram Reels in HD (2026 Guide)',
'h1' => 'How to Download Instagram Reels in HD',
'sections' => [
['h2' => 'Step 1: Copy the link', 'blocks' => [/* ... */]],
],
'faq' => [
['q' => '...', 'a' => '...'],
],
];
Why plain arrays and not Markdown? Two reasons:
-
I18n keys inline. Every content block can reference
{brand},{url:reels}, or any translation placeholder, and the rendering layer resolves them against the current locale. Markdown forces an extra parser and a second syntax for placeholders. -
Structured FAQ for schema.org. The same array feeds both the HTML template and the JSON-LD
FAQPageschema. One source of truth, no drift between what the user sees and what Google's rich-result parser sees.
Downside: writing content in PHP arrays is uglier than Markdown. For five tool pages and a handful of blog posts, I'll take ugly-but-structured over pretty-but-two-renderers.
3. The upstream proxy and the Cloudflare curl fingerprint
The actual "download" logic proxies to a third-party Instagram scraper API and re-decodes its output. Nothing surprising there — except for one thing that cost me an afternoon.
Cloudflare-fronted APIs sometimes 403 PHP's libcurl, even when a curl binary on the same machine works fine.
This is TLS fingerprinting. PHP's libcurl on Windows (with the OpenSSL build I had) presents a ClientHello that Cloudflare's bot engine doesn't love. The system curl.exe presents a different one and sails through. On the Linux production host both work, because the libcurl there is a different build.
The fix was embarrassing in its simplicity — try proc_open first, fall back to curl_init:
function fetch_upstream(string $url, array $post): string
{
if (function_exists('proc_open')) {
// System curl binary — argv form, no shell
$cmd = ['curl', '-s', '--max-time', '20'];
foreach ($post as $k => $v) {
$cmd[] = '--data-urlencode';
$cmd[] = "$k=$v";
}
$cmd[] = $url;
return run_proc($cmd);
}
// Shared hosts often disable proc_open — use libcurl there
return curl_fetch($url, $post);
}
Shared hosts commonly disable proc_open and exec, so the libcurl path is the only one that works in production for me. Locally the system-curl path takes over. Same code, two TLS fingerprints.
If I had to do this again I'd probably reach for curl-impersonate on the server, but for a side project, keeping both paths is five lines and zero infra.
4. LiteSpeed cache was my worst debugging day
The site went live, I pushed a sitemap fix, hard-reloaded, and still saw the old XML. Cloudflare purge did nothing. cPanel's LiteSpeed cache flush did nothing.
Turned out LiteSpeed's server-level cache was serving stale bytes regardless of what Cloudflare said. The fix was a single .htaccess line:
<IfModule LiteSpeed>
CacheLookup off
</IfModule>
For a downloader where every request is dynamic anyway, there's nothing to cache server-side. Turning it off cost zero performance and ended the ghost-response nightmare. If you're on LiteSpeed shared hosting and your server seems to ignore you, this is the first thing to check.
5. 21 languages, one file each, no gettext
The lang/ directory has 21 PHP files — en.php, pl.php, es.php, …, each returning a flat associative array. No .po, no .mo, no gettext compilation step. A translation helper looks up the current locale's array and falls back to English if a key is missing.
For a small content site, this is lighter than running gettext on shared hosting (where recompiling .mo files is a hassle) and makes "edit a translation" literally an edit-the-file operation. Not elegant, but boring in all the right ways.
What I'd skip if I did it again
- Nothing in the admin. There is no admin. Everything is code. For a personal-scale site, an admin UI is a liability — one more thing to secure. Content lives in git.
- No ORM. No database. The whole thing is stateless. State lives at the upstream API and in the user's browser.
- No build step. CSS is hand-written. JS is one file, served straight. Deploy is a zip upload to cPanel.
The site is live at snapinsta.pl if you want to poke at it. Boring stack, boring code, bored-into-the-ground reliability. That's the goal.
If you've got questions about any part of the setup — the router, the decoder, the i18n layer, the Cloudflare quirk — drop a comment. Happy to go deeper.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.