Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Streaming the File Download

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We have a method that will allow us to open a stream of the file's contents. But... how can we send that to the user? We're used to returning a Response object or a JsonResponse object where we already have the response as a string or array. But if you want to stream something to the user without reading it all into memory, you need a special class called StreamedResponse.

Add $response = new StreamedResponse(). This takes one argument - a callback. At the bottom, return this.

... lines 1 - 11
use Symfony\Component\HttpFoundation\StreamedResponse;
... lines 13 - 18
class ArticleReferenceAdminController extends BaseController
{
... lines 21 - 79
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 82 - 84
$response = new StreamedResponse(function() use ($reference, $uploaderHelper) {
... lines 86 - 89
});
return $response;
}
... lines 94 - 95

Here's the idea: we can't just start streaming the response or echo'ing content right now inside the controller: Symfony's just not ready for that yet, it has more work to do, more headers to set, etc. That's why we normally create a Response object and later, when it's ready, Symfony echo's the response's content for us.

With a StreamedResponse, when Symfony is ready to finally send the data, it executes our callback and then we can do whatever we want. Heck, we can echo 'foo' and that's what the user would see.

Add a use statement and bring $reference and $uploaderHelper into the callback's scope so we can use them. To send a file stream to the user, it looks a little strange. Start with $outputStream set to fopen('php://output') and wb.

... lines 1 - 11
use Symfony\Component\HttpFoundation\StreamedResponse;
... lines 13 - 18
class ArticleReferenceAdminController extends BaseController
{
... lines 21 - 79
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 82 - 84
$response = new StreamedResponse(function() use ($reference, $uploaderHelper) {
$outputStream = fopen('php://output', 'wb');
... lines 87 - 89
});
return $response;
}
... lines 94 - 95

We usually use fopen to write to a file. But this special php://output allows us to write to the "output" stream - a fancy way of saying that anything we write to this stream will just get "echo'ed" out. Next, set $fileStream to $uploaderHelper->readStream() and pass this the path to the file - something like article_reference/symfony-best-practices-blah-blah.pdf.

Oh, except, we don't have an easy way to do that yet! In our Article entity, we added a nice getImagePath() method that read the constant from UploaderHelper and added the filename. I like that.

Let's copy that and go do the exact same thing in ArticleReference. At the bottom, paste and rename this to getFilePath(). Let's add a return type too - I probably should have done that in Article. Then, re-type the r on UploaderHelper to get the use statement, change the constant to ARTICLE_REFERENCE and update the method call to getFilename().

... lines 1 - 4
use App\Service\UploaderHelper;
... lines 6 - 10
class ArticleReference
{
... lines 13 - 91
public function getFilePath(): string
{
return UploaderHelper::ARTICLE_REFERENCE.'/'.$this->getFilename();
}
}

Great! Back in the controller, pass $reference->getFilePath() and then false for the $isPublic argument.

... lines 1 - 18
class ArticleReferenceAdminController extends BaseController
{
... lines 21 - 79
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 82 - 84
$response = new StreamedResponse(function() use ($reference, $uploaderHelper) {
$outputStream = fopen('php://output', 'wb');
$fileStream = $uploaderHelper->readStream($reference->getFilePath(), false);
... lines 88 - 89
});
return $response;
}
... lines 94 - 95

Finally, now that we have a "write" stream and a "read" stream, we can use a function called stream_copy_to_stream() to... do exactly that! Copy $fileStream to $outputStream.

... lines 1 - 18
class ArticleReferenceAdminController extends BaseController
{
... lines 21 - 79
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 82 - 84
$response = new StreamedResponse(function() use ($reference, $uploaderHelper) {
$outputStream = fopen('php://output', 'wb');
$fileStream = $uploaderHelper->readStream($reference->getFilePath(), false);
stream_copy_to_stream($fileStream, $outputStream);
});
return $response;
}
... lines 94 - 95

There ya go! The fanciest way of echo'ing content that you've probably ever seen, but it avoids eating memory.

Setting the Content-Type

Try it out! Refresh and... it works... sort of. We are sending the file contents... but the browser is clearly not handling it well. The reasons is that we haven't told the browser what type of file this is, so it's just treating it like the world's ugliest web page.

And... hey! Remember when we stored the $mimeType of the file in the database? Whelp, that's about to come in handy... big time! Add $response->headers->set() with Content-Type set to $reference->getMimeType().

... lines 1 - 18
class ArticleReferenceAdminController extends BaseController
{
... lines 21 - 79
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 82 - 90
$response->headers->set('Content-Type', $reference->getMimeType());
... lines 92 - 93
}
}

Try it again. Hello PDF!

Content-Disposition: Forcing Download

Another thing you might want to do is force the browser to download the file. It's really up to you. By default, based on the Content-Type, the browser may try to open the file - like it is here - or have the user download it. To force the browser to always download the file, we can leverage a header called Content-Disposition.

This header has a very specific format, so Symfony comes with a helper to create it. Say $disposition = HeaderUtils::makeDisposition(). For the first argument, we'll tell it whether we want the user to download the file, or open it in the browser by passing HeaderUtils::DISPOSITION_ATTACHMENT or DISPOSITION_INLINE.

... lines 1 - 10
use Symfony\Component\HttpFoundation\HeaderUtils;
... lines 12 - 80
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 83 - 92
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
... line 95
);
... lines 97 - 99
}
}

Next, pass it the filename.

This is especially cool because, without this, the browser would probably try to call the file... just... "download" - because that's the last part of the URL. Now it will use $reference->getOriginalFilename().

Tip

If your original filename is not in ASCII characters, add a 3rd argument to HeaderUtils::makeDisposition to provide a "fallback" filename.

... lines 1 - 10
use Symfony\Component\HttpFoundation\HeaderUtils;
... lines 12 - 80
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 83 - 92
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$reference->getOriginalFilename()
);
... lines 97 - 99
}
}

Before we set this header, I just want you to see what it looks like. So, dd($disposition)

... lines 1 - 10
use Symfony\Component\HttpFoundation\HeaderUtils;
... lines 12 - 80
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 83 - 92
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$reference->getOriginalFilename()
);
dd($disposition);
... lines 98 - 99
}
}

move over, refresh and... there it is. It's just a string, like any other header - but it has this specific format, which is why Symfony has a helper method.

Set this on the actual response with $response->headers->set('Content-Disposition', $disposition).

... lines 1 - 19
class ArticleReferenceAdminController extends BaseController
{
... lines 22 - 80
public function downloadArticleReference(ArticleReference $reference, UploaderHelper $uploaderHelper)
{
... lines 83 - 96
$response->headers->set('Content-Disposition', $disposition);
... lines 98 - 99
}
}

Try it one more time. Yes! It downloads and uses the original filename.

Next: let's make this all way cooler by uploading instantly via AJAX.

Leave a comment!

17
Login or Register to join the conversation
Ad F. Avatar
Ad F. Avatar Ad F. | posted 3 years ago | edited

what do you do when a user from Japan uploads a weird file name

HeaderUtils::makeDisposition('attachment', 'ςЎβξЯиęł ŁĮωέ.docx')

throws - The filename fallback must only contain ASCII characters.

1 Reply

Hey Ad F.

Thanks for the good question!
For such cases there is 3rd argument in makeDisposition method. You should provide your own generated ASCII filename, for the fallback.

Cheers!

1 Reply
Juan-Etxenike Avatar
Juan-Etxenike Avatar Juan-Etxenike | posted 1 year ago | edited

Hello, my local server is running on windows with XAMPP, I am having a problem to stream the file download due to the incompatibility between the slashes in windows and unix systems. I have followed this tutorial and I find myself with this problem:

<blockquote>Unable to read file
from location: invoices/FACTURA-2022-1.pdf.
fopen(D:\www\rakatangaclean\var\uploads\invoices/FACTURA-2022-1.pdf):
Failed to open stream: No such file or directory</blockquote>
The error trace points at :

$fileStream = $uploadHelper->readStream($invoice->getFilePath(), false);

My "invoices" entity's getFilePath property is defined the following way:


public function getFilePath(): ?string
    {
        return UploadHelper::INVOICES.'\FACTURA-'.$this->getInvoiceNumber().'.pdf';
    }

In uploadHelper.php I have defined the INVOICES folder this way:

const INVOICES = "invoices"

At the oneup_flysystem.yaml


private_uploads_adapter:
            local:
                location: '%kernel.project_dir%\var\uploads'

I find it difficult to overcome the slashes issue between windows and unix systems.

Reply

Hey Juan E.!

Hmmm. Ok, first question: does the path (ignoring possible "slash problems") D:\www\rakatangaclean\var\uploads\invoices/FACTURA-2022-1.pdf actually exist, or not? If it doesn't, then we have some other problem. If it DOES, then yes, it seems like a "slash" problem... though I'm pretty sure that PHP is pretty good these days at just "figuring it out" when it comes to the slashes.

So if the file DOES exist... and so we think that we have a "slash" problem, try this. In some controller somewhere, put this code:


dd(file_exists('D:\\www\\rakatangaclean\\var\\uploads\\invoices/FACTURA-2022-1.pdf'));

That is the same path from the error... except that I have two \\... just because that's what you need to do in a PHP string when you want a backslash. Anyways, in theory, that should return false, because this is the path that originally wasn't working. If it DOES return false, try "playing with the slashes" to see if you can get it to return true. For example, does changing /FACTURA-2022-1.pdf to <br />\\FACTURA-2022-1.pdf make it return true? If so, then we can be 100% sure that we have a "slash problem".

Let me know what you find out :).

Cheers!

Reply

Hello!
Is it possible to set the disposition to "inline" but in a new tab?
Thx!

Reply

Hey Lydie,

Yes, sure! Actually, there's nothing server side :) You just need to add target="_blank" for that link, it will be opened in a new tab then. I.e. it's a plain HTML feature, not PHP :)

Cheers!

1 Reply

Hi there, thanks for the great tutorial!

I was wondering, is there a reason why you don't add validation to stream_copy_to_stream? The documentation mentions that it also returns false on failure. We've been adding those validation to streams so far so it made me wonder.

Cheers!

Reply

Hey julien_bonnier!

Excellent question! There was no reason for this - just an oversight on my part, to be honest. For an abundance of caution, adding a check would probably make sense :).

Cheers!

Reply

I am having weird header already sent error while generating the CSV and exporting for download on the fly. The header already sent error is at the line:

fputcsv($fp, $row);```


here is my code:

$response = new StreamedResponse(function () use ($output) {

		// Create CSV.
		$fp = fopen('php://output', 'wb');
		$x  = 0;

		// Empty record set returned.
		if (empty($output->data)) {
			fputcsv($fp, ['No data found']);
		} else {
			// Add data to CSV interating through each record
			foreach ($output->data as $row) {
				if ($x++ == 0) {
					// If this is the first time round add headers to start of CSV
					fputcsv($fp, [$output->getDocClassification()]);
					fputcsv($fp, []);
					fputcsv($fp, array_keys($row));
				}
				fputcsv($fp, $row);
			}
		}

		fclose($fp);
	}
	);

	$disposition = $response->headers->makeDisposition(
		ResponseHeaderBag::DISPOSITION_ATTACHMENT,
		$this->getFilename()
	);
	$response->headers->set('Content-Type', 'text/csv; charset=utf-8');
	$response->headers->set('Content-Disposition', $disposition);

	return $response;



Interesting thing this CSV is generated for very simple data for example around 50 rows with simple text, however, when data is large and contain some text it throws this header already sent error.

Please can you advise what may be wrong?
Reply

Hey Ghazanfar,

Hm, it sounds like you hit some kinda of limit, maybe memory limit, maybe something else, not sure. Do you see a Symfony error? Try to check the entire stack trace, it may contain some tips.

Also, first of all, make sure that your data structure is valid... you may hit some edge case where data is not valid, i.e. maybe you need one more if statement for handle that edge case.

If nothing helps - I'd recommend you to debug things more thoroughly. E.g. use logger and log data for every iteration right in the beginning, *before* you will handle those data. Then, run again and when it failed with the error you mentioned - look at logs and see on what data it failed. You can also exclude those data and see if no errors. If you always have error on the same iteration - most probably you hit some kind of limit in your PHP configuration.

I hope this helps!

Cheers!

Reply
VERMON Avatar
VERMON Avatar VERMON | posted 3 years ago | edited

Hi guys :)

I've just few question about the way to serve the file, why do not use the file shortcut method, or directly use the BinaryFileResponse ?

return $this->file('invoice_3241.pdf', 'my_invoice.pdf', ResponseHeaderBag::DISPOSITION_INLINE);
It return a stream. (see: https://symfony.com/doc/current/controller.html#streaming-file-responses)

Or in my case I prefer the BinaryFileResponse to use X-SendFile (https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/) and do not serve at all the file with PHP, but just return a header that say to Nginx: "Hey, give him this file please." Then, this method can also stream the File by doing a new Sream('path/of/the/file'); then give it to the BinaryFileResponse. (see: https://symfony.com/doc/current/components/http_foundation.html#serving-files)

Reply

Hey VERMON!

Excellent questions!

why do not use the file shortcut method, or directly use the BinaryFileResponse

In this case, we didn't use the file() method (which creates a BinaryFileResponse as you know) simply because we are not working with a local file on our filesystem. Instead, we're working with Flysystem, and the file could be stored locally... or could be up on S3. The only thing we know is that we're able to get a "stream" - and so we use the StreamedResponse instead. If I knew that I had a local file, I'd totally use BinaryFileResponse - it's much easier :).

Or in my case I prefer the BinaryFileResponse to use X-SendFile

You're right - this is a faster way than serving files through PHP. But again, you need to know for sure that you're working with local files in the filesystem - and we don't have that guarantee in this situation because we're using Flysystem. In reality, in a real app, if I were "streaming" larger files, because download performance of these files would be so important, I would "avoid" using Flysystem in this one situation and instead, either (A) stream the files from the filesystem using X-SendFile or (B) use signed URLs with S3/CloudFront so that the user can securely download the files directly from S3/CloudFront instead of proxying through our system (which will definitely slow things down).

Cheers!

Reply

Hi! How can I extract the file from a given path? I make things more clear, I have some private files which are accessible only to some user categories. So I have the var->upload->category1 category2 etc. I need to extract all the files in one of this directory in order to show them the files who belogs to that category. How can I do that?

Reply

Hey Gballocc7

Are you going to read those files from S3 or from your local file system? Anyway, when working with big files the best thing you can do is to open a stream socket like Ryan demonstrates in this episode. And for reading a directory, if you are using the same library as on the tutorial (league/flysystem) then I believe the method you are looking for is listContents. Check out its docs: https://flysystem.thephpleague.com/docs/usage/filesystem-api

I hope this helps. Cheers!

Reply
Steve-D Avatar
Steve-D Avatar Steve-D | posted 4 years ago

Hi

I'm building a new system which includes file uploads. I'm trying to do the first part of the tutorial (images) but need them to be private yet viewable in the system. Using public images means they are accessible via the url irrespective of the system being secured.

I've got the images and thumbs in the /var directory but can't show them.

I've added a private_uploads_base_url in services.yaml, edited liip_imagine.yaml so the filesystem_service uses oneup_flysystem.private_uploads_filesystem_filesystem. I've added a getPrivatePath() in the UploaderHelper, new twig function for the asset but I can't seem to output the image.

Any tips on where I'm going wrong or how I can use the second part of the tutorial (private files) but adapt it to have a view of the image and not a download?

Many thanks

Steve

Reply

Hey Steve D.

If you already store private files and implemented the endpoint for being able to download them, then it should be very easy to instead of returning a file just return the image content. This guide might help you out: https://www.quora.com/How-d...
Basically you just need to set the proper headers and return the image content as your response. In your frontend you just have to call such endpoint inside a HTML img element

Cheers!

Reply
Steve-D Avatar

A great help thank you Diego very much appreciated, I'm now successfully returning the image. However, it is the original image and very large. I'm trying (so far without any luck) to filter this image using the Liipimagine bundle and pass the filtered image back.

Stop the press. Since I began writing this I've actually managed to sort it. Taken me a good few hours to sort but happy now.

If anyone reads this and needs help let me know.

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on Symfony 4 but works great in Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.87.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.9.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.0.3
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.4
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.2.3
        "symfony/console": "^4.0", // v4.2.3
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.2.3
        "symfony/framework-bundle": "^4.0", // v4.2.3
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.2.3
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.2.3
        "symfony/validator": "^4.0", // v4.2.3
        "symfony/web-server-bundle": "^4.0", // v4.2.3
        "symfony/yaml": "^4.0", // v4.2.3
        "twig/extensions": "^1.5" // v1.5.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
        "symfony/dotenv": "^4.0", // v4.2.3
        "symfony/maker-bundle": "^1.0", // v1.11.3
        "symfony/monolog-bundle": "^3.0", // v3.3.1
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.2.3
    }
}
userVoice