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 SubscribeThere are a few minor problems with our new Flysystem
integration. Let's clean them up before they bite us!
The first is that using file_get_contents()
eats memory: it reads the entire contents of the file into PHP's memory. That's not a huge deal for tiny files, but it could be a big deal if you start uploading bigger stuff. And, it's just not necessary.
For that reason, in general, when you use Flysystem
, instead of using methods like ->write()
or ->update()
, you should use ->writeStream()
or ->updateStream()
.
... lines 1 - 10 | |
class UploaderHelper | |
{ | |
... lines 13 - 24 | |
public function uploadArticleImage(File $file): string | |
{ | |
... lines 27 - 34 | |
$this->filesystem->writeStream( | |
... lines 36 - 37 | |
); | |
... lines 39 - 43 | |
} | |
... lines 45 - 51 | |
} |
It works the same, except that we need to pass a stream instead of the contents. Create the stream with $stream = fopen($file->getPathname())
and, because we just need to read the file, use the r
flag. Now, pass stream instead of the contents.
... lines 1 - 10 | |
class UploaderHelper | |
{ | |
... lines 13 - 24 | |
public function uploadArticleImage(File $file): string | |
{ | |
... lines 27 - 33 | |
$stream = fopen($file->getPathname(), 'r'); | |
$this->filesystem->writeStream( | |
self::ARTICLE_IMAGE.'/'.$newFilename, | |
$stream | |
); | |
... lines 39 - 43 | |
} | |
... lines 45 - 51 | |
} |
Yea... that's it! Same thing, but no memory issues. But we do need to add one more detail after: if is_resource($stream)
, then fclose($stream)
. The "if" is needed because some Flysystem adapters close the stream by themselves.
... lines 1 - 10 | |
class UploaderHelper | |
{ | |
... lines 13 - 24 | |
public function uploadArticleImage(File $file): string | |
{ | |
... lines 27 - 33 | |
$stream = fopen($file->getPathname(), 'r'); | |
$this->filesystem->writeStream( | |
self::ARTICLE_IMAGE.'/'.$newFilename, | |
$stream | |
); | |
if (is_resource($stream)) { | |
fclose($stream); | |
} | |
... lines 42 - 43 | |
} | |
... lines 45 - 51 | |
} |
Ok, for problem number two, go back to /admin/article
. Log back in with password engage
, edit an article, and go select an image - how about astronaut.jpg
. Hit update and... it works! So what's the problem? Well, we just replaced an existing image with this new one. Does the old file still exist in our uploads directory? Absolutely! But it probably shouldn't. When an article image is updated, let's delete the old file.
In UploaderHelper
, add a second argument - a nullable string argument called $existingFilename
.
... lines 1 - 10 | |
class UploaderHelper | |
{ | |
... lines 13 - 24 | |
public function uploadArticleImage(File $file, ?string $existingFilename): string | |
{ | |
... lines 27 - 47 | |
} | |
... lines 49 - 55 | |
} |
This is nullable because sometimes there may not be an existing file to delete. At the bottom, it's beautifully simple: if an $existingFilename
was passed, then $this->filesystem->delete()
and pass that the full path, which will be self::ARTICLE_IMAGE.'/'.$existingFilename
.
... lines 1 - 10 | |
class UploaderHelper | |
{ | |
... lines 13 - 24 | |
public function uploadArticleImage(File $file, ?string $existingFilename): string | |
{ | |
... lines 27 - 42 | |
if ($existingFilename) { | |
$this->filesystem->delete(self::ARTICLE_IMAGE.'/'.$existingFilename); | |
} | |
... lines 46 - 47 | |
} | |
... lines 49 - 55 | |
} |
Done! You can see the astronaut file that we're using right now. Oh, but first, head over to ArticleAdminController
: we need to pass this new argument. Let's see - this is the edit()
action - so pass $article->getImageFilename()
.
... lines 1 - 17 | |
class ArticleAdminController extends BaseController | |
{ | |
... lines 20 - 57 | |
public function edit(Article $article, Request $request, EntityManagerInterface $em, UploaderHelper $uploaderHelper) | |
{ | |
... lines 60 - 64 | |
if ($form->isSubmitted() && $form->isValid()) { | |
... lines 66 - 67 | |
if ($uploadedFile) { | |
$newFilename = $uploaderHelper->uploadArticleImage($uploadedFile, $article->getImageFilename()); | |
... line 70 | |
} | |
... lines 72 - 80 | |
} | |
... lines 82 - 85 | |
} | |
... lines 87 - 124 | |
} |
In new()
, you can really just pass null
- there will not be an article image. But I'll pass getImageFilename()
to be consistent.
... lines 1 - 17 | |
class ArticleAdminController extends BaseController | |
{ | |
... lines 20 - 23 | |
public function new(EntityManagerInterface $em, Request $request, UploaderHelper $uploaderHelper) | |
{ | |
... lines 26 - 28 | |
if ($form->isSubmitted() && $form->isValid()) { | |
... lines 30 - 35 | |
if ($uploadedFile) { | |
$newFilename = $uploaderHelper->uploadArticleImage($uploadedFile, $article->getImageFilename()); | |
... line 38 | |
} | |
... lines 40 - 46 | |
} | |
... lines 48 - 51 | |
} | |
... lines 53 - 124 | |
} |
Oh, and there's one other place we need update: ArticleFixtures
. Down here, just pass null
: we are never updating.
... lines 1 - 13 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 16 - 90 | |
private function fakeUploadImage(): string | |
{ | |
... lines 93 - 97 | |
return $this->uploaderHelper | |
->uploadArticleImage(new File($targetPath), null); | |
} | |
} |
Try it! Here is the current astronaut image. Now, move over, upload rocket.jpg
this time and update! Back in the directory... there's rocket and astronaut is gone! Love it!
In a perfect system, the existing file will always exist, right? I mean, how could a filename get set on the entity... without being uploaded? Well, what if we're developing locally... and maybe we clear out the uploads directory to test something - or we clear out the uploads directory in our automated tests. What would happen?
Let's find it! Empty uploads/
. Back in our browser, the image preview still shows up because this is rendering a thumbnail file - which we didn't delete - but the original image is totally gone. Select earth.jpeg
, update and... it fails! It fails on $this->filesystem->delete()
.
This may be the behavior you want: if something weird happens and the old file is gone, please explode so that I know. But, I'm going to propose something slightly less hardcore. If the old file doesn't exist for some reason, I don't want the entire process to fail... it really doesn't need to.
The error from Flysystem is a FileNotFoundException
from League\Flysystem
. In UploaderHelper
wrap that line in a try-catch. Let's catch that FileNotFoundException
- the one from League\Flysystem
... lines 1 - 5 | |
use League\Flysystem\FileNotFoundException; | |
... lines 7 - 12 | |
class UploaderHelper | |
{ | |
... lines 15 - 29 | |
public function uploadArticleImage(File $file, ?string $existingFilename): string | |
{ | |
... lines 32 - 47 | |
if ($existingFilename) { | |
try { | |
$this->filesystem->delete(self::ARTICLE_IMAGE.'/'.$existingFilename); | |
} catch (FileNotFoundException $e) { | |
... line 52 | |
} | |
} | |
... lines 55 - 56 | |
} | |
... lines 58 - 64 | |
} |
That'll fix that problem... but I don't love doing this. Honestly, I hate silencing errors. One of the benefits of throwing an exception is that we can configure Symfony to notify us of errors via the logger. At SymfonyCasts, we send all errors to a Slack channel so we know if something weird is going on... not that we ever have bugs. Pfff.
Here's what I propose: a soft failure: we don't fail, but we do log that an error happened. Back on the constructor, autowire a new argument: LoggerInterface $logger
. I'll hit Alt + Enter
and select initialize fields to create that property and set it.
... lines 1 - 7 | |
use Psr\Log\LoggerInterface; | |
... lines 9 - 12 | |
class UploaderHelper | |
{ | |
... lines 15 - 20 | |
private $logger; | |
public function __construct(FilesystemInterface $publicUploadsFilesystem, RequestStackContext $requestStackContext, LoggerInterface $logger) | |
{ | |
... lines 25 - 26 | |
$this->logger = $logger; | |
} | |
... lines 29 - 64 | |
} |
Now, down in the catch, say $this->logger->alert()
- alert is one of the highest log levels and I usually send all logs that are this level or higher to a Slack channel. Inside, how about: "Old uploaded file %s was missing when trying to delete" - and pass $existingFilename
.
... lines 1 - 12 | |
class UploaderHelper | |
{ | |
... lines 15 - 29 | |
public function uploadArticleImage(File $file, ?string $existingFilename): string | |
{ | |
... lines 32 - 47 | |
if ($existingFilename) { | |
try { | |
$this->filesystem->delete(self::ARTICLE_IMAGE.'/'.$existingFilename); | |
} catch (FileNotFoundException $e) { | |
$this->logger->alert(sprintf('Old uploaded file "%s" was missing when trying to delete', $existingFilename)); | |
} | |
} | |
... lines 55 - 56 | |
} | |
... lines 58 - 64 | |
} |
Thanks to this, the user gets a smooth experience, but we get notified so we can figure out how the heck the old file disappeared.
Move over and re-POST the form. Now it works. And to prove the log worked, check out the terminal tab where we're running the Symfony web server: it's streaming all of our logs here. Scroll up and... there it is!
Old uploaded file "rocket..." was missing when trying to delete
Ok, there's one more thing I want to tighten up. If one of the calls to the Filesystem
object fails... what do you think will happen? An exception? Hold Command or Ctrl and click on writeStream()
. Check out the docs: we will get an exception if we pass an invalid stream or if the file already exists. But for any other type of failure, maybe a network error... instead of an exception, the method just returns false!
Actually, that's not completely true - it depends on your adapter. For example, if you're using the S3 adapter and there's a network error, it may throw its own type of exception. But the point is this: if any of the Filesystem methods fail, you might not get an exception: it might just return false.
For that reason, I like to code defensively. Assign this to a $result
variable.
... lines 1 - 12 | |
class UploaderHelper | |
{ | |
... lines 15 - 29 | |
public function uploadArticleImage(File $file, ?string $existingFilename): string | |
{ | |
... lines 32 - 39 | |
$result = $this->filesystem->writeStream( | |
... lines 41 - 42 | |
); | |
... lines 44 - 65 | |
} | |
... lines 67 - 73 | |
} |
Then say: if ($result === false)
, let's throw our own exception - I do want to know that something failed:
Could not write uploaded file "%s"
and pass $newFilename
.
... lines 1 - 12 | |
class UploaderHelper | |
{ | |
... lines 15 - 29 | |
public function uploadArticleImage(File $file, ?string $existingFilename): string | |
{ | |
... lines 32 - 39 | |
$result = $this->filesystem->writeStream( | |
... lines 41 - 42 | |
); | |
... line 44 | |
if ($result === false) { | |
throw new \Exception(sprintf('Could not write uploaded file "%s"', $newFilename)); | |
} | |
... lines 48 - 65 | |
} | |
... lines 67 - 73 | |
} |
Copy that and do the same for delete
:
Could not delete old uploaded file "%s"
with $existingFilename
.
... lines 1 - 12 | |
class UploaderHelper | |
{ | |
... lines 15 - 29 | |
public function uploadArticleImage(File $file, ?string $existingFilename): string | |
{ | |
... lines 32 - 52 | |
if ($existingFilename) { | |
try { | |
$result = $this->filesystem->delete(self::ARTICLE_IMAGE.'/'.$existingFilename); | |
if ($result === false) { | |
throw new \Exception(sprintf('Could not delete old uploaded file "%s"', $existingFilename)); | |
} | |
} catch (FileNotFoundException $e) { | |
... line 61 | |
} | |
} | |
... lines 64 - 65 | |
} | |
... lines 67 - 73 | |
} |
I'm throwing this error instead of just logging something because this would truly be an exceptional case - we shouldn't let things continue. But, it's your call.
Let's make sure this all works: move over and select the stars
file - or... actually the "Earth from Moon" photo. Update and... got it!
Next: let's teach LiipImagineBundle to play nice with Flysytem. After all, if we move Flysystem to S3, but LiipImagineBundle is still looking for the source files locally... well... we're not going to have a great time.
Hey Kirill,
I believe this is the video you're looking for https://symfonycasts.com/screencast/symfony4-fundamentals/logger-trait
By the way, at the top of the page, there's a search box where you can look for specific content just in this tutorial or through the whole site
Cheers!
Hello, since we need to use FlysystemOperator, the "write" and "delete" methods return "void". Because of this, we are unable to run these snippets:
$result = $this->filesystem->writeStream(
self::ARTICLE_IMAGE.'/'.$newFilename,
$stream
);
if ($result === false) {
throw new \Exception(sprintf('Could not write uploaded file "%s"', $newFilename));
}
if (is_resource($stream)) {
fclose($stream);
}
if ($existingFilename) {
try {
$result = $this->filesystem->delete(self::ARTICLE_IMAGE.'/'.$existingFilename);
if ($result === false) {
throw new \Exception(sprintf('Could not delete old uploaded file "%s"', $existingFilename));
}
} catch (FileNotFoundException $e) {
$this->logger->alert(sprintf('Old uploaded file "%s" was missing when trying to delete', $existingFilename));
}
}
We therefore cannot apply the "defensive" strategy demonstrated in the video. Is there an alternative?
I replaced it by :
try {
$this->filesystem->writeStream(
self::ARTICLE_IMAGE.'/'.$newFilename,
$stream
);
} catch (FilesystemException $e) {
throw new \Exception($e->getMessage());
}
if (is_resource($stream)) {
fclose($stream);
}
if ($existingFilename) {
try {
$this->filesystem->delete(self::ARTICLE_IMAGE.'/'.$existingFilename);
} catch (FileNotFoundException $e) {
$this->logger->alert(sprintf('Old uploaded file "%s" was missing when trying to delete', $existingFilename));
}
}
What do you think ?
Hey Kiuega !
Ah yes - a difference between Flysystem v1 and v2. I need to see if we need some notes to the video related to this!
I think what you're referring to, specifically, is this: https://flysystem.thephpleague.com/v2/docs/what-is-new/#no-more-success-result-booleans
So, in v2, life is actually simpler. You don't need to code as defensively because the operation will work or it will NOT work and you will get an exception (without us needing to if it worked).
The code you put above, I think, is close - but we can simplify a bit more :).
A) For the writeStream
, there's no need to have a try/catch around this. Just call the method. Either it will work, or an exception will be thrown. So, it will have the same behavior that you see now (a different exception instance will be thrown, but that's fine).
B) For the delete, you technically don't need the try/catch here either, unless you wanted to avoid failing (500 error) if the file is not found. If that's what you want to accomplish, then your code is perfect: you're catching just that one type of error. I don't think you need the if ($existingFilename)
part, but that's minor - and maybe there is more to your code that I'm not seeing.
Cheers!
Hey Kiuega
Looks like you did a good investigation! The code you did is a good alternative! Sorry that you got difficulties with this part of course.
Cheers!
What is meant by "some streams close themselves"? At -7.49? Why are we closing something that already closes itself?
Hey Cameron,
> Why are we closing something that already closes itself?
Actually, it's the reverse... Ryan says:
> The "if" is needed because some Flysystem adapters close the stream by themselves.
So we need that "if" to find *those* steams that were not closed by themselves... and if they were not - we close it ourselves in that if :)
Makes sense for you now?
Cheers!
I have noticed that Exception for the stream write result is thrown before we check if stream was closed, does it matter? Maybe we should throw it after?
Hey Krzysztof K.
That's a good question. I'm not sure if after throwing an error the resource will be closed automatically, or if it may introduce any bugs. So, if you want to play defensively you may want to close it first and then check for errors.
Cheers!
// 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
}
}
Hey! You've noticed about symfonycasts where you have sent error logs to Slack, can you please give the name or link of this tutorial? Thanks!