Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

Protected Downloads That Don't Melt PHP: X-Sendfile and X-Accel-Redirect

C
Carlo Fontanos
· 2 min read

Selling digital products means files that must be access-checked (no public URLs) but are also large (no fun to stream through PHP). The naive version does both jobs in PHP:

// auth check, then...
readfile($path);    // a PHP worker babysits every byte of a 500MB download

readfile works - my own store starts there - but each download occupies a PHP-FPM worker for its whole duration. A handful of customers on slow connections downloading big ZIPs, and your worker pool (typically a few dozen) is eaten by file-copying that Apache or nginx do far better. The offload pattern splits the jobs: PHP decides, the web server delivers.

nginx: X-Accel-Redirect

# nginx.conf - an internal location invisible to direct requests
location /protected-files/ {
    internal;
    alias /var/storage/products/;
}
// download.php - after your token/order validation:
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $niceName . '"');
header('X-Accel-Redirect: /protected-files/' . $storedName);
exit;   // PHP is DONE - nginx streams the file, worker freed instantly

The internal flag means users can never hit /protected-files/ directly - only a backend response header can invoke it. nginx then serves with sendfile syscalls, range-request support (resumable downloads!) and none of your worker pool.

Apache: X-Sendfile

# requires mod_xsendfile (a2enmod xsendfile or package install)
XSendFile On
XSendFilePath /var/storage/products
header('X-Sendfile: /var/storage/products/' . $storedName);
header('Content-Disposition: attachment; filename="' . $niceName . '"');
exit;

Same contract, different spelling. (LiteSpeed supports X-LiteSpeed-Location; caddy and others have equivalents - the pattern is universal even when the header isn't.)

The portable core + graceful fallback

function sendProtectedFile(string $path, string $name): never
{
    header('Content-Disposition: attachment; filename="' . rawurlencode($name) . '"');
    header('Content-Type: application/octet-stream');

    match (true) {
        server_supports('xaccel') => header('X-Accel-Redirect: ' . accelPath($path)),
        server_supports('xsendfile') => header('X-Sendfile: ' . $path),
        default => (function () use ($path) {          // fallback: chunked readfile
            header('Content-Length: ' . filesize($path));
            session_write_close();                     // don't hold the session lock!
            $fh = fopen($path, 'rb');
            while (!feof($fh)) { echo fread($fh, 65536); flush(); }
        })(),
    };
    exit;
}

Even the fallback has two lessons baked in: chunked reads keep memory flat (readfile is fine too, but never file_get_contents+echo on big files), and closing the session before a long stream stops one download from freezing the customer's other tabs.

Auth in PHP (tokens, expiry, download counts - compared safely), bytes in the web server, filenames via Content-Disposition so storage names stay opaque. That trio is how file delivery looks when it's done properly - and the offload headers are a one-afternoon upgrade to bolt onto the readfile version you already have.

C
Written by Carlo Fontanos

Full-stack web developer sharing practical tutorials and building tools that ship.

Got something on your mind?

My inbox is open - no forms disappearing into the void here.

  • Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
  • Hire me for a project Have something custom in mind? Let's talk scope and timelines.
  • Product support Bought something here? I'll help you get it running.

I usually reply within 1-2 business days.

Message sent!

Your details are only used to reply to you.

Keep reading