DEV Community

SnapInsta Team
SnapInsta Team

Posted on

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 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';
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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' => '...'],
    ],
];
Enter fullscreen mode Exit fullscreen mode

Why plain arrays and not Markdown? Two reasons:

  1. 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.
  2. Structured FAQ for schema.org. The same array feeds both the HTML template and the JSON-LD FAQPage schema. 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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)