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 SubscribeOpen up src/DataFixtures/ArticleFixtures.php
. Here's how this works: this function creates 10 articles whenever we run bin/console doctrine:fixtures:load
. It's a cool helper we created in our Symfony series. But, the setImageFilename()
stuff is now a problem. We know that the image filename needs to be the name of a file that lives inside of the uploads/article_image
directory - something like astronaut-blah-blah.jpg
. Right now, the fixtures use faker to select a random item in $articleImages
- this private property. So, it's setting imageFilename
to either asteroid.jpeg
, mercury.jpeg
or lightspeed.png
.
This worked before because those images are committed to our repository in the public/images
directory and we were pointing to that path in our template. When we run doctrine:fixtures:load
, it does create 10 Article objects and it does set the image filename to one of these three filenames. But on the homepage... it doesn't work! There is no upload/article_image/lightspeed.png
file. We need to re-think how this works.
How? By faking the file upload inside the fixtures. It's kinda...beautiful! Our UploaderHelper
service is already really good at moving things into the right spot - why not reuse it here?
Inside ArticleFixtures
, create a public function __construct()
. Add an UploaderHelper $uploaderHelper
argument and I'll hit ALT + Enter
and select initialize fields to create that property and set it.
... lines 1 - 7 | |
use App\Service\UploaderHelper; | |
... lines 9 - 12 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 15 - 26 | |
private $uploaderHelper; | |
public function __construct(UploaderHelper $uploaderHelper) | |
{ | |
$this->uploaderHelper = $uploaderHelper; | |
} | |
... lines 33 - 90 | |
} |
Next, lets "cut" the 3 files in the public/images
directory: we're going to move them to a different spot, because they no longer need to be publicly accessible. You'll see what I mean. In the src/DataFixtures
directory, create a new folder here called images/
and paste them! Yep! They are no longer in the public/images/
directory.
Because these test images are committed to git, I'm going to commit this move - it'll help us in a minute when things... ah... sorta go wrong horribly wrong. Yes! We are planning for disaster!
Here's the idea: we'll use the UploaderHelper
down here, point it at one of these 3 files, and have it, sort of, "fake" upload it. Start with $randomImage =
, copy the faker code, and paste. This is now one of the three random image filenames.
... lines 1 - 12 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 15 - 33 | |
protected function loadData(ObjectManager $manager) | |
{ | |
$this->createMany(10, 'main_articles', function($count) use ($manager) { | |
... lines 37 - 63 | |
$randomImage = $this->faker->randomElement(self::$articleImages); | |
... lines 65 - 78 | |
}); | |
... lines 80 - 81 | |
} | |
... lines 83 - 90 | |
} |
Next, in UploaderHelper
, what I'd like to do is call uploadArticleImage()
and basically say:
Hey! Pretend like
asteroid.jpeg
is a file that was just uploaded. And... ya know... do all your normal stuff and move it into theuploads/
directory.
This is easier than you think: in the fixtures class, set $imageFilename
to $this->uploaderHelper->uploadArticleImage()
. What I want to do is now say new UploadedFile()
and point it at one of the images. The problem is that you can't really create a fake UploadedFile
object. Internally, it's bound to the PHP uploading process - weird stuff will happen if you try to create one outside of that context.
That's ok! It just means we need to dig deeper! Go back into UploaderHelper
. Hold Command or Ctrl and click to open the UploadedFile
class. This lives in the Symfony\HttpFoundation\File
namespace and extends a class called File
that lives in the same directory.
The File
class is awesome: it simply represents... any file on your filesystem, regardless of whether it's an uploaded file or just a normal file. And, if you look closely, the vast majority of the methods we've been using come from this class - not from UploadedFile
. And we can create a File
object outside of an upload context.
So back in ArticleFixtures
, instead of creating a new UploadedFile()
, say new File()
- the one from HttpFoundation
. Pass this the path to the random image: __DIR__.'/images/'
and then $randomImage
, which will be one of these image filenames.
... lines 1 - 10 | |
use Symfony\Component\HttpFoundation\File\File; | |
... line 12 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 15 - 33 | |
protected function loadData(ObjectManager $manager) | |
{ | |
$this->createMany(10, 'main_articles', function($count) use ($manager) { | |
... lines 37 - 63 | |
$randomImage = $this->faker->randomElement(self::$articleImages); | |
$imageFilename = $this->uploaderHelper | |
->uploadArticleImage(new File(__DIR__.'/images/'.$randomImage)); | |
... lines 67 - 78 | |
}); | |
... lines 80 - 81 | |
} | |
... lines 83 - 90 | |
} |
Now, take $imageFilename
- that'll be whatever the final filename is on the system after moving it, and set that onto the entity.
That's beautiful! In UploaderHelper
, we need to make this work not with an UploadedFile
object, but with the parent File
. Change the type-hint to File
- again, make sure you get the one from HttpFoundation
or you will have no fun. To keep things clear, I'll Refactor -> Rename this variable to $file
.
... lines 1 - 5 | |
use Symfony\Component\HttpFoundation\File\File; | |
... lines 7 - 9 | |
class UploaderHelper | |
{ | |
... lines 12 - 23 | |
public function uploadArticleImage(File $file): string | |
{ | |
... lines 26 - 34 | |
$file->move( | |
$destination, | |
$newFilename | |
); | |
... lines 39 - 40 | |
} | |
... lines 42 - 48 | |
} |
Let's see: everything looks happy, ah - except for getClientOriginalName()
: that method does not exist in File
- it only exists in UploadedFile
. Ok, let's get fancy then: if $file
is an instanceof UploadedFile
, we can say $originalFilename = $file->getClientOriginalName()
. Else, set $originalFilename
to $file->getFilename()
- that's just the name of the file on the filesytem.
... lines 1 - 5 | |
use Symfony\Component\HttpFoundation\File\File; | |
... lines 7 - 9 | |
class UploaderHelper | |
{ | |
... lines 12 - 23 | |
public function uploadArticleImage(File $file): string | |
{ | |
... lines 26 - 27 | |
if ($file instanceof UploadedFile) { | |
$originalFilename = $file->getClientOriginalName(); | |
} else { | |
$originalFilename = $file->getFilename(); | |
} | |
... lines 33 - 40 | |
} | |
... lines 42 - 48 | |
} |
After this, delete the pathinfo()
stuff - we can move that to the next line. Inside urlize()
, re-add the pathinfo()
and pass the same second argument: PATHINFO_FILENAME
.
... lines 1 - 5 | |
use Symfony\Component\HttpFoundation\File\File; | |
... lines 7 - 9 | |
class UploaderHelper | |
{ | |
... lines 12 - 23 | |
public function uploadArticleImage(File $file): string | |
{ | |
... lines 26 - 27 | |
if ($file instanceof UploadedFile) { | |
$originalFilename = $file->getClientOriginalName(); | |
} else { | |
$originalFilename = $file->getFilename(); | |
} | |
$newFilename = Urlizer::urlize(pathinfo($originalFilename, PATHINFO_FILENAME)).'-'.uniqid().'.'.$file->guessExtension(); | |
... lines 34 - 40 | |
} | |
... lines 42 - 48 | |
} |
I think that's all we need! Let's completely clear out the uploads/
directory. Now, find your terminal and run:
php bin/console doctrine:fixtures:load
Woh! The file src/DataFixtures/images/asteroid.jpeg
does not exist? Hmm. Check this out: it did upload two files before going all "explody" on us. Oh, but those original files are missing! Of course! We're using $file->move()
. So it is working, but instead of copying the files, it's moving them, and the originals are disappearing.
Let's get those files back. Run:
git status
And undelete them with:
git checkout src/DataFixtures/images
Much better. Let's clean out the uploads directory again.
We do want to use $file->move()
because we do want to move the uploaded file in normal circumstances. So, to get around this, in the fixtures, let's copy the original file to a temporary spot. Start with $fs = new Filesystem()
- that's a handy object for doing filesystem operations.
... lines 1 - 10 | |
use Symfony\Component\Filesystem\Filesystem; | |
... lines 12 - 13 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 16 - 34 | |
protected function loadData(ObjectManager $manager) | |
{ | |
$this->createMany(10, 'main_articles', function($count) use ($manager) { | |
... lines 38 - 64 | |
$randomImage = $this->faker->randomElement(self::$articleImages); | |
$fs = new Filesystem(); | |
... lines 67 - 82 | |
}); | |
... lines 84 - 85 | |
} | |
... lines 87 - 94 | |
} |
Next, $targetPath = sys_get_temp_dir().'/'.$randomImage
. And then use $fs->copy()
. We want to copy the original file path into $targetPath
.
... lines 1 - 10 | |
use Symfony\Component\Filesystem\Filesystem; | |
... lines 12 - 13 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 16 - 34 | |
protected function loadData(ObjectManager $manager) | |
{ | |
$this->createMany(10, 'main_articles', function($count) use ($manager) { | |
... lines 38 - 65 | |
$fs = new Filesystem(); | |
$targetPath = sys_get_temp_dir().'/'.$randomImage; | |
$fs->copy(__DIR__.'/images/'.$randomImage, $targetPath, true); | |
... lines 69 - 82 | |
}); | |
... lines 84 - 85 | |
} | |
... lines 87 - 94 | |
} |
Inside File
, pass the temporary path.
... lines 1 - 10 | |
use Symfony\Component\Filesystem\Filesystem; | |
... lines 12 - 13 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 16 - 34 | |
protected function loadData(ObjectManager $manager) | |
{ | |
$this->createMany(10, 'main_articles', function($count) use ($manager) { | |
... lines 38 - 65 | |
$fs = new Filesystem(); | |
$targetPath = sys_get_temp_dir().'/'.$randomImage; | |
$fs->copy(__DIR__.'/images/'.$randomImage, $targetPath, true); | |
$imageFilename = $this->uploaderHelper | |
->uploadArticleImage(new File($targetPath)); | |
... lines 71 - 82 | |
}); | |
... lines 84 - 85 | |
} | |
... lines 87 - 94 | |
} |
Ok, let's try it again!
php bin/console doctrine:fixtures:load
No error, our original files still exist and... we have a directory full of, fake uploaded files. Now try the homepage. Beautiful. What I really love about this is that we're not doing anything fancy or tricky in our fixtures: we're literally using our upload system.
Though, I don't love having all of this logic right in the middle of this already-long function: it's not super obvious what it does. Let's do some cleanup: copy all of this. And at the bottom, create a new private function fakeUploadImage()
that will return a string
.
... lines 1 - 13 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 16 - 90 | |
private function fakeUploadImage(): string | |
{ | |
... lines 93 - 99 | |
} | |
} |
Paste all that logic and return the $this->uploaderHelper
line. It selects a random image, uploads it and returns the path.
... lines 1 - 13 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 16 - 90 | |
private function fakeUploadImage(): string | |
{ | |
$randomImage = $this->faker->randomElement(self::$articleImages); | |
$fs = new Filesystem(); | |
$targetPath = sys_get_temp_dir().'/'.$randomImage; | |
$fs->copy(__DIR__.'/images/'.$randomImage, $targetPath, true); | |
return $this->uploaderHelper | |
->uploadArticleImage(new File($targetPath)); | |
} | |
} |
Back up top, delete all this stuff and say $imageFilename = $this->fakeUploadImage()
.
... lines 1 - 13 | |
class ArticleFixtures extends BaseFixture implements DependentFixtureInterface | |
{ | |
... lines 16 - 33 | |
protected function loadData(ObjectManager $manager) | |
{ | |
$this->createMany(10, 'main_articles', function($count) use ($manager) { | |
... lines 38 - 64 | |
$imageFilename = $this->fakeUploadImage(); | |
... lines 66 - 77 | |
}); | |
... lines 79 - 80 | |
} | |
... lines 82 - 100 | |
} |
Let's run those fixtures one more time!
php bin/console doctrine:load:fixtures
When it finishes... we have some new files... and the homepage is shiny! That's a solid fixture system.
Next: we'll take our first step towards storing uploaded files in the cloud by integrating the gorgeous Flysystem library.
Hey Aurélien Dachier!
Hmmm. Do you have any config in your services.yaml file for UploaderHelper? And also, does your "bind" line look like this? https://symfonycasts.com/screencast/symfony-uploads/uploader-service#codeblock-9d72416900
Usually, this error would make me think that you have some config that makes it look like "uploadsPath" is a service - some config like:
services:
_defaults:
bind:
$uploadsPath: '@uploadsPath'
Or perhaps:
services:
App\Service\UploaderHelper:
arguments:
$uploadsPath: '@uploadsPath'
Let me know what you see in your services.yaml file :).
Cheers!
Hi ! Thanks for your answer, here's what i have in my services.yaml file :
parameters:
app.path.product_images: /uploads/images/products
app.path.event_image: /uploads/images/events
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
- '../src/Tests/'
App\Service\UploaderHelper:
class: App\Service\UploaderHelper
arguments:
$uploadsPath: '@uploadsPath'
$requestStackContext: '@requestStackContext'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
Did I make something wrong ?
Hey Aurélien Dachier!
Excellent - this is very useful! When you have the @uploadsPath
value in this file, it tells Symfony to look for a service whose id is uploadsPath
, which is not what you're intending. My guess is that you actually want to pass in one of those two parameters. In that case, you would use something like $uploadsPath: '%app.path.product_images%'
.
Let me know if that helps :).
Cheers!
Hello,
When i learned for the first time how to uploading file, it was recommended by Fabien Potencier to create an entity for the file and manage the UploadeFile class in the entity with doctrine events, for example App\Entity\Image. Today, i always follow this paradigm for my apps and manage all the files as mapped relations with Doctrine, so the approach of this tutorial perturbs me. Do you consider is not a good practise ? if yes, why ?
Source (fifth item) : https://openclassrooms.com/...
Regards
Hey Jean M.!
I think it's a fine approach :). It's not the one I prefer - but there is not super important reason for that. The thing I don't like about the entity solution (though, again, it's not such a major detail) is that it requires you to have a property on your entity that exists *just* to temporarily hold the UploadedFile - it's not meant to be persisted. Using an unmapped field in a form removes the need for this property.
The other thing that I don't prefer with lifecycle callbacks for this, is that it requires all of your logic to be inside of your entity - but I would usually prefer to have this logic in a service. This also limits you to saving things to the local filesystem - you can't use something like Flysystem, because you can't access services from inside your entity.
So, I see the lifecycle events approach to file uploading as a simpler, easier way of doing things. But I personally prefer to split things up a bit more, just because I like to have the logic in a service. But it sounds like you have had a lot of luck with the simpler approach - which does not surprise me. So if you want to keep doing it, no complaints from me ;).
Cheers!
Ok, it was an old tutorial, an easier way for basic usage i think but services are better way for customization. No problem for changing my paradigm. So i continue the course.
Thanks!
5:50 Can't you just use $test argument of UploadedFile constructor? It seems to be not marked as internal. Personally I don't like messing up my code logic just for fixtures. :)
Hey TomaszGasior
That's a good point, I think you could use that flag argument if you want but I don't see the uploading logic being polluted with fixtures logic, all the pesky details of copying files live inside fixtures
Cheers!
Hey Manoj,
Thank you for your feedback! We're really happy you like it and it's useful for you ;)
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
}
}
Hello,
When I follow the steps, i got an error saying "The service "App\Service\UploaderHelper" has a dependency on a non-existent service "uploadsPath"." Can anyone help me ?