Protected Downloads That Don't Melt PHP: X-Sendfile and X-Accel-Redirect
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.
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.