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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe 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.
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.