63

I have a 200MB file that I want to give to a user via download. However, since we want the user to only download this file once, we are doing this:

echo file_get_contents('http://some.secret.location.com/secretfolder/the_file.tar.gz');

to force a download. However, this means that the whole file has to be loaded in memory, which usually doesn't work. How can we stream this file to them, at some kb per chunk?

animuson
  • 53,861
  • 28
  • 137
  • 147
Filo Stacks
  • 1,951
  • 2
  • 20
  • 20
  • 1
    Use `stream_copy_to_stream(fopen('file.ext', 'rb')), STDOUT)` to pipe the stream to stdout. If your default buffer size needs adjusting, use `stream_set_chunk_size($fp, $size)` – Christoffer Bubach Dec 21 '19 at 23:53

5 Answers5

84

Try something like this (source http://teddy.fr/2007/11/28/how-serve-big-files-through-php/):

<?php
define('CHUNK_SIZE', 1024*1024); // Size (in bytes) of tiles chunk

// Read a file and display its content chunk by chunk
function readfile_chunked($filename, $retbytes = TRUE) {
    $buffer = '';
    $cnt    = 0;
    $handle = fopen($filename, 'rb');

    if ($handle === false) {
        return false;
    }

    while (!feof($handle)) {
        $buffer = fread($handle, CHUNK_SIZE);
        echo $buffer;
        ob_flush();
        flush();

        if ($retbytes) {
            $cnt += strlen($buffer);
        }
    }

    $status = fclose($handle);

    if ($retbytes && $status) {
        return $cnt; // return num. bytes delivered like readfile() does.
    }

    return $status;
}

// Here goes your code for checking that the user is logged in
// ...
// ...

if ($logged_in) {
    $filename = 'path/to/your/file';
    $mimetype = 'mime/type';
    header('Content-Type: '.$mimetype );
    readfile_chunked($filename);

} else {
    echo 'Tabatha says you haven\'t paid.';
}
?>
Edson Horacio Junior
  • 3,033
  • 2
  • 29
  • 50
diagonalbatman
  • 17,340
  • 3
  • 31
  • 31
53

Use fpassthru(). As the name suggests, it doesn't read the entire file into memory prior to sending it, rather it outputs it straight to the client.

Modified from the example in the manual:

<?php

// the file you want to send
$path = "path/to/file";

// the file name of the download, change this if needed
$public_name = basename($path);

// get the file's mime type to send the correct content type header
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $path);

// send the headers
header("Content-Disposition: attachment; filename=$public_name;");
header("Content-Type: $mime_type");
header('Content-Length: ' . filesize($path));

// stream the file
$fp = fopen($path, 'rb');
fpassthru($fp);
exit;

If you would rather stream the content directly to the browser rather than a download (and if the content type is supported by the browser, such as video, audio, pdf etc) then remove the Content-Disposition header.

starbeamrainbowlabs
  • 5,692
  • 8
  • 42
  • 73
Darren Gordon
  • 699
  • 5
  • 6
  • will this stream in a way that user may **resume** the download if it was interrupted? – azerafati Jul 28 '15 at 07:11
  • 2
    @Bludream Not in the current form. To handle resuming of downloads you should check for the client header ```Range```. If a bytes value is present you can then use ```fseek()``` on the file, and send an appropriate ```Content-Range``` header before sending it. – Darren Gordon Nov 09 '15 at 11:17
  • 3
    I don't recommend this for large files, as the original question states. Smaller files work fine, but I'm using fpassthru on a large file and my download died because "allowed memory size was exhausted". – Magmatic Jul 27 '16 at 18:53
  • 5
    @Magmatic Yes, as the manual says for `fpassthru` it will output the file to the output buffer. But if you call `ob_end_flush()` first, then output buffering is disabled, so you won't hit your memory limit :-D – artfulrobot Nov 01 '18 at 11:19
  • 1
    Using ob_end_flush() before call to fpassthru() fails for me. – David Spector Jul 10 '22 at 20:45
12

I found this method in http://codesamplez.com/programming/php-html5-video-streaming-tutorial

And it works very well for me

   <?php

class VideoStream
{
    private $path = "";
    private $stream = "";
    private $buffer = 102400;
    private $start  = -1;
    private $end    = -1;
    private $size   = 0;

    function __construct($filePath) 
    {
        $this->path = $filePath;
    }

    /**
     * Open stream
     */
    private function open()
    {
        if (!($this->stream = fopen($this->path, 'rb'))) {
            die('Could not open stream for reading');
        }

    }

    /**
     * Set proper header to serve the video content
     */
    private function setHeader()
    {
        ob_get_clean();
        header("Content-Type: video/mp4");
        header("Cache-Control: max-age=2592000, public");
        header("Expires: ".gmdate('D, d M Y H:i:s', time()+2592000) . ' GMT');
        header("Last-Modified: ".gmdate('D, d M Y H:i:s', @filemtime($this->path)) . ' GMT' );
        $this->start = 0;
        $this->size  = filesize($this->path);
        $this->end   = $this->size - 1;
        header("Accept-Ranges: 0-".$this->end);

        if (isset($_SERVER['HTTP_RANGE'])) {

            $c_start = $this->start;
            $c_end = $this->end;

            list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);
            if (strpos($range, ',') !== false) {
                header('HTTP/1.1 416 Requested Range Not Satisfiable');
                header("Content-Range: bytes $this->start-$this->end/$this->size");
                exit;
            }
            if ($range == '-') {
                $c_start = $this->size - substr($range, 1);
            }else{
                $range = explode('-', $range);
                $c_start = $range[0];

                $c_end = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $c_end;
            }
            $c_end = ($c_end > $this->end) ? $this->end : $c_end;
            if ($c_start > $c_end || $c_start > $this->size - 1 || $c_end >= $this->size) {
                header('HTTP/1.1 416 Requested Range Not Satisfiable');
                header("Content-Range: bytes $this->start-$this->end/$this->size");
                exit;
            }
            $this->start = $c_start;
            $this->end = $c_end;
            $length = $this->end - $this->start + 1;
            fseek($this->stream, $this->start);
            header('HTTP/1.1 206 Partial Content');
            header("Content-Length: ".$length);
            header("Content-Range: bytes $this->start-$this->end/".$this->size);
        }
        else
        {
            header("Content-Length: ".$this->size);
        }  

    }

    /**
     * close curretly opened stream
     */
    private function end()
    {
        fclose($this->stream);
        exit;
    }

    /**
     * perform the streaming of calculated range
     */
    private function stream()
    {
        $i = $this->start;
        set_time_limit(0);
        while(!feof($this->stream) && $i <= $this->end) {
            $bytesToRead = $this->buffer;
            if(($i+$bytesToRead) > $this->end) {
                $bytesToRead = $this->end - $i + 1;
            }
            $data = fread($this->stream, $bytesToRead);
            echo $data;
            flush();
            $i += $bytesToRead;
        }
    }

    /**
     * Start streaming video content
     */
    function start()
    {
        $this->open();
        $this->setHeader();
        $this->stream();
        $this->end();
    }
}

To use this class, you will have to write simple code like as below:

$stream = new VideoStream($filePath);
$stream->start();
BIDS Salvaterra
  • 316
  • 3
  • 8
11

Take a look at the example from the manual page of fsockopen():

$fp = fsockopen("www.example.com", 80, $errno, $errstr, 30);
if (!$fp) {
    echo "$errstr ($errno)<br />\n";
} else {
    $out = "GET / HTTP/1.1\r\n";
    $out .= "Host: www.example.com\r\n";
    $out .= "Connection: Close\r\n\r\n";
    fwrite($fp, $out);
    while (!feof($fp)) {
        echo fgets($fp, 128);
    }
    fclose($fp);
}

This will connect to www.example.com, send a request then get and echo the response in 128 byte chunks. You may want to make it more than 128 bytes.

rid
  • 61,078
  • 31
  • 152
  • 193
  • 2
    How is this tranfering a file...? Especially if the file is outside the webroot that solution will not work. – Pierre Aug 05 '13 at 13:16
  • 1
    Correct me if I'm wrong but using raw sockets forces you to implement yourself every HTTP feature you're faced to, such as redirections, compression, encryption, chunked encoding... It might work in specific scenarios but it isn't the best general purpose solution. – Álvaro González Feb 28 '14 at 08:16
6

I ran into this problem as well using readfile() to force a download. The memory problem lies not within readfile, rather with ouput buffering.

Just make sure you switch off output buffering before readfile, and the problem should be fixed.

if (ob_get_level()) ob_end_clean();
readfile($yourfile);

Works for files with a size much larger than the allocated memory limit.

Gilly
  • 9,212
  • 5
  • 33
  • 36
  • 2
    According to the [PHP Manual for readfile()](https://www.php.net/manual/en/function.readfile.php#refsect1-function.readfile-notes) "[it] will not present any memory issues, even when sending large files, on its own. If you encounter an out of memory error ensure that output buffering is off with ob_get_level()". So this seems like an underrated answer. – Steffen Wenzel May 11 '22 at 05:24