File Upload Field in a Form
We're rocking! We know what it looks like to upload a file in Symfony: we can work with the UploadedFile
object and we know how to move the file around. That feels good!
It's time to talk about how to work with a file upload field inside a Symfony form. And then, we also need to save the filename of the uploaded image to our Article
entity. Because, ultimately, on the homepage, we need to render each image next to the article.
What your Entity Should Look Like
In the src/Entity
directory, let's look at the Article
entity. Ok great: the entity is already setup! It has an $imageFilename
field that is a string. This is important: the uploaded file will be stored... somewhere: on your server, in the cloud, in your imagination - it doesn't matter. But in the database, the only thing you will store is the string filename.
Adding the FileType to the Form
The form that handles this page lives at src/Form/ArticleFormType.php
. In ArticleAdminController
... if you scroll up a little bit... here is the edit()
action and you can see it using this ArticleFormType
. Right now, this is a nice traditional form: it handles the request and saves the Article
to the database. Beautifully... boring!
In ArticleFormType
, add a new field with ->add()
and call it imageFilename
because that's the name of the property inside Article
. For the type, use FileType::class
.
// ... lines 1 - 11 | |
use Symfony\Component\Form\Extension\Core\Type\FileType; | |
// ... lines 13 - 19 | |
class ArticleFormType extends AbstractType | |
{ | |
// ... lines 22 - 28 | |
public function buildForm(FormBuilderInterface $builder, array $options) | |
{ | |
// ... lines 31 - 34 | |
$builder | |
// ... lines 36 - 53 | |
->add('imageFilename', FileType::class) | |
; | |
// ... lines 56 - 88 | |
} | |
// ... lines 90 - 152 | |
} |
But... there's a problem with this. And if you already see it, extra credit points for you! Move over and refresh. Woh.
The form's view data is expected to be an instance of class
File
but it is astring
.
Um... ok. The problem is not super obvious... but it clearly hates something about our new field. Here's the explanation: we know that when you upload a file, Symfony gives you an UploadedFile
object, not a string
. But, the imageFilename
field here on Article
... that is a string
! Connecting the form field directly to the string property doesn't make sense. We're missing a layer in the middle: something that can work with the UploadedFile
object, move the file, and then set the new filename onto the property.
Using an Unmapped Field
How can we do that? Change the field name to just imageFile
. There is no property on our entity with this name... so this, on its own, will not work. Pretty commonly, you'll see people create this property on their entity, just to make the form work. They don't persist this property to the database with Doctrine... so the idea works, but I don't love it.
Instead, we'll use a trick that we talked a lot about in our forms tutorial: add an option to the field: 'mapped' => false
.
// ... lines 1 - 19 | |
class ArticleFormType extends AbstractType | |
{ | |
// ... lines 22 - 28 | |
public function buildForm(FormBuilderInterface $builder, array $options) | |
{ | |
// ... lines 31 - 34 | |
$builder | |
// ... lines 36 - 53 | |
->add('imageFile', FileType::class, [ | |
'mapped' => false | |
]) | |
; | |
// ... lines 58 - 90 | |
} | |
// ... lines 92 - 154 | |
} |
If you've never seen this before, we'll explain it in a minute. Now that we have a new imageFile
field, let's go render it! Open edit.html.twig
. Remove the HTML form - we're done with that. The Symfony form lives in _form.html.twig
. After the title, add {{ form_row(articleForm.imageFile }}
.
{{ form_start(articleForm) }} | |
{{ form_row(articleForm.title, { | |
label: 'Article title' | |
}) }} | |
{{ form_row(articleForm.imageFile) }} | |
// ... lines 6 - 23 | |
{{ form_end(articleForm) }} |
Nothing special here.
This submits back to ArticleAdminController::edit()
. Go inside the $form->isValid()
block. When you have an unmapped field, the data will not be put onto your Article
object. So, how can we get it? dd($form['imageFile']->getData())
.
// ... lines 1 - 16 | |
class ArticleAdminController extends BaseController | |
{ | |
// ... lines 19 - 48 | |
public function edit(Article $article, Request $request, EntityManagerInterface $em) | |
{ | |
// ... lines 51 - 55 | |
if ($form->isSubmitted() && $form->isValid()) { | |
dd($form['imageFile']->getData()); | |
// ... lines 58 - 66 | |
} | |
// ... lines 68 - 71 | |
} | |
// ... lines 73 - 128 | |
} |
Let's try that! Go back to your browser and hit enter on the URL: we need the form to totally re-render. Hey! There's our new field! Select the astronaut again. Um... did that work? Cause... I don't see the filename on my field. Yes: it did work - we don't see anything because of a display bug if you're using Symfony's Bootstrap 4 form theme. We'll talk about that later. But, the file is attached to the field. Hit Update!
Yes! It's our beloved UploadedFile
object! We totally know how to work with that! Oh, but before we do: I want to point out something cool. Inspect element and find the form
tag. Hey! It has the enctype="multipart/form-data"
attribute! We get that for free because we use the {{ form_start() }}
function to render the <form>
tag. As soon as there is even one file upload field in the form, Symfony adds this attribute for you. High-five team!
Moving the Uploaded File
Time to finish this. Let's upload a different file - earth.jpeg
. And... there's the dump. We have two jobs in our controller: move this file to the final location and store the new filename on the $imageFilename
property. Back in the controller, scroll down to temporaryUploadAction()
, steal all its code, and delete it.
Up in edit()
, remove the dd()
and set this to an $uploadedFile
variable. Add the same inline phpdoc as last time
// ... lines 1 - 16 | |
class ArticleAdminController extends BaseController | |
{ | |
// ... lines 19 - 48 | |
public function edit(Article $article, Request $request, EntityManagerInterface $em) | |
{ | |
// ... lines 51 - 55 | |
if ($form->isSubmitted() && $form->isValid()) { | |
/** @var UploadedFile $uploadedFile */ | |
$uploadedFile = $form['imageFile']->getData(); | |
$destination = $this->getParameter('kernel.project_dir').'/public/uploads'; | |
// ... lines 60 - 77 | |
} | |
// ... lines 79 - 82 | |
} | |
// ... lines 84 - 121 | |
} |
then paste the code. Yep! We'll move the file to public/uploads
and give it a unique filename. Take off the dd()
around move()
.
// ... lines 1 - 16 | |
class ArticleAdminController extends BaseController | |
{ | |
// ... lines 19 - 48 | |
public function edit(Article $article, Request $request, EntityManagerInterface $em) | |
{ | |
// ... lines 51 - 55 | |
if ($form->isSubmitted() && $form->isValid()) { | |
/** @var UploadedFile $uploadedFile */ | |
$uploadedFile = $form['imageFile']->getData(); | |
$destination = $this->getParameter('kernel.project_dir').'/public/uploads'; | |
$originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); | |
$newFilename = Urlizer::urlize($originalFilename).'-'.uniqid().'.'.$uploadedFile->guessExtension(); | |
$uploadedFile->move( | |
$destination, | |
$newFilename | |
); | |
// ... lines 68 - 77 | |
} | |
// ... lines 79 - 82 | |
} | |
// ... lines 84 - 121 | |
} |
Now, call $article->setImageFilename($newFilename)
// ... lines 1 - 16 | |
class ArticleAdminController extends BaseController | |
{ | |
// ... lines 19 - 48 | |
public function edit(Article $article, Request $request, EntityManagerInterface $em) | |
{ | |
// ... lines 51 - 55 | |
if ($form->isSubmitted() && $form->isValid()) { | |
/** @var UploadedFile $uploadedFile */ | |
$uploadedFile = $form['imageFile']->getData(); | |
$destination = $this->getParameter('kernel.project_dir').'/public/uploads'; | |
$originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); | |
$newFilename = Urlizer::urlize($originalFilename).'-'.uniqid().'.'.$uploadedFile->guessExtension(); | |
$uploadedFile->move( | |
$destination, | |
$newFilename | |
); | |
$article->setImageFilename($newFilename); | |
// ... lines 69 - 77 | |
} | |
// ... lines 79 - 82 | |
} | |
// ... lines 84 - 121 | |
} |
and let Doctrine save the entity, just like it already was.
Beautiful! I do want to point out that the $newFilename
string that we're storing in the database is just the filename: it doesn't contain the directory or the word uploads
: it's... the filename. Oh, for my personal sanity, let's upload things into an article_image
sub-directory: that'll be cleaner when we start uploading multiple types of things. Remove the old files.
// ... lines 1 - 16 | |
class ArticleAdminController extends BaseController | |
{ | |
// ... lines 19 - 48 | |
public function edit(Article $article, Request $request, EntityManagerInterface $em) | |
{ | |
// ... lines 51 - 55 | |
if ($form->isSubmitted() && $form->isValid()) { | |
/** @var UploadedFile $uploadedFile */ | |
$uploadedFile = $form['imageFile']->getData(); | |
$destination = $this->getParameter('kernel.project_dir').'/public/uploads/article_image'; | |
// ... lines 60 - 77 | |
} | |
// ... lines 79 - 82 | |
} | |
// ... lines 84 - 121 | |
} |
Moment of truth! Find your browser, roll up your sleeves, and refresh! Um... it probably worked? In the uploads/
directory... yea! There's our Earth file! Let's see what the database looks like - find your terminal and run:
php bin/console doctrine:query:sql 'SELECT * FROM article WHERE id = 1'
Let's see, the id of this article is 1. Yes! the image_filename
column is totally set! Fist-pumping time!
Avoid Processing when no Upload
Oh, but there is one tiny thing we need to clean up before moving on. What if we just want to, I don't know, edit the article's title, but we don't need to change the image. No problem - hit Update! Oh... That's HTML5 validation. You might remember from the forms tutorial that this required
attribute is added to every field... unless you're using form field type guessing. It's annoying - fix it by adding 'required' => false
.
// ... lines 1 - 19 | |
class ArticleFormType extends AbstractType | |
{ | |
// ... lines 22 - 28 | |
public function buildForm(FormBuilderInterface $builder, array $options) | |
{ | |
// ... lines 31 - 34 | |
$builder | |
// ... lines 36 - 53 | |
->add('imageFile', FileType::class, [ | |
// ... line 55 | |
'required' => false, | |
]) | |
; | |
// ... lines 59 - 91 | |
} | |
// ... lines 93 - 155 | |
} |
Let's try it again. Refresh, change the title, submit and... oof.
Call to a member function
getClientOriginalName
on null
Of course! We're not uploading a file! So the $uploadedFile
variable is null! That's ok! If the user didn't upload a file, we don't need to do any of this logic. In other words, if ($uploadedFile)
, then do all of that. Otherwise, skip it!
// ... lines 1 - 16 | |
class ArticleAdminController extends BaseController | |
{ | |
// ... lines 19 - 48 | |
public function edit(Article $article, Request $request, EntityManagerInterface $em) | |
{ | |
// ... lines 51 - 55 | |
if ($form->isSubmitted() && $form->isValid()) { | |
/** @var UploadedFile $uploadedFile */ | |
$uploadedFile = $form['imageFile']->getData(); | |
if ($uploadedFile) { | |
$destination = $this->getParameter('kernel.project_dir').'/public/uploads/article_image'; | |
$originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); | |
$newFilename = Urlizer::urlize($originalFilename).'-'.uniqid().'.'.$uploadedFile->guessExtension(); | |
$uploadedFile->move( | |
$destination, | |
$newFilename | |
); | |
$article->setImageFilename($newFilename); | |
} | |
// ... lines 71 - 79 | |
} | |
// ... lines 81 - 84 | |
} | |
// ... lines 86 - 123 | |
} |
Refresh now. Got it!
Next: This is looking good! Except that... we need this exact same logic in the new()
action. To make a truly killer upload system, we need to refactor the upload logic into a reusable service.
Hello, I have an issue with rendering the input file..Well the input file doesn't show, just the label appears.What is the problem?