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!

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
        "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.0", // v1.6.2
        "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
    }
}