Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Uploads, multipart/form-data & UploadedFile

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

This page uses a Symfony form. And we will learn how to add a file upload field to a form object. But... let's start simpler - with a good old-fashioned HTML form.

The controller behind this page live at src/Controller/ArticleAdminController.php, and we're on the edit() action. Create a totally new, temporary endpoint: public function temporaryUploadAction(). We're going to create an HTML form in our template, put an input file field inside, and make it submit to this action. Add the @Route() with, how about, /admin/upload/test and name="upload_test". But... don't do anything else yet.

... lines 1 - 14
class ArticleAdminController extends BaseController
{
... lines 17 - 69
/**
* @Route("/admin/upload/test", name="upload_test")
*/
public function temporaryUploadAction(Request $request)
{
... line 75
}
... lines 77 - 113
}

Copy the route name, then open the template for the edit page: templates/article_admin/edit.html.twig. The Symfony form lives inside the _form.html.twig template. So, above that form tag, add a new form tag, with method="POST" and action="" set to {{ path('upload_test') }}. Inside, we only need one thing <input type="file">. We need to give this a name so we can reference it on the server: how about name="image".

Finally, add <button type="submit"> and I'll add some classes so that this isn't the ugliest button ever. Say: Upload!

... lines 1 - 5
<form method="POST" action="{{ path('upload_test') }}">
<input type="file" name="image">
<button type="submit" class="btn btn-primary">Upload!</button>
</form>
<hr>
{{ include('article_admin/_form.html.twig', {
button_text: 'Update!'
}) }}
... lines 17 - 24

That's it! The simplest possible file upload setup: one field, one button.

Fetching the File in the Controller

In some ways, uploading a file is really no different than any other form field: you're always just sending data to the server where each data has a key equal to its name attribute. So, the same as any form, to read the submitted data, we'll need the request object. Add a new argument with a Request type-hint - the one from HttpFoundation - $request. Then say: dd() - that's dump & die - $request->files->get('image'). I'm using image because that's the name attribute used on the field.

... lines 1 - 10
use Symfony\Component\HttpFoundation\Request;
... lines 12 - 72
public function temporaryUploadAction(Request $request)
{
dd($request->files->get('image'));
}
... lines 77 - 115

Cool! What do you think this will dump out? A string filename? An array? An object? Let's find out! Choose a file - I'll go into my I <3 Space directory, and select the astronaut photo! Upload!

multipart/form-data

Oh! It's... null!? I did not see that coming. If you're ever uploading a file and it's totally not working, you've probably made the same mistake I just did. Go back to the template and add an attribute to the form enctype="multipart/form-data".

... lines 1 - 5
<form method="POST" action="{{ path('upload_test') }}" enctype="multipart/form-data">
... lines 7 - 9
</form>
... lines 11 - 24

Yep! Mysteriously, you never need this on your forms... until you have a file upload field. It basically tells your browser to send the data in a different format. We're going to see exactly what this means soon cause we are crushing the magic behind uploads.

Fortunately, PHP understand this format and this format supports file uploads. Refresh the form so the new attribute is rendered. Let's choose the astronaut again. And before hitting Upload, open up your developer tools and go to the Network tab: I want to see what this request looks like. Hit upload!

Nice! This time we get an UploadedFile object full of useful data.

But before we dive into that, look down at the network tools and find the POST request we just made. If you look at the request headers... here it is: our browser sent a Content-Type: multipart/form-data header. This is because of the enctype attribute. It also added this weird boundary=----WebkitFormBoundary, blah, blah, blah thing.

Ok: this stuff is super-nerdy-cool. Normally, when you do not have that enctype attribute, when you submit a form, all of the data is sent in the body of the request in a big string full of what looks like query parameters. That's kind of invisible to us, because PHP parses all of that and makes the data available.

But when you add the multipart/form-data attribute, it tells our browser to send the data in a different format. It's actually kind of hard to see what the body of these requests look like - Chrome hides it. No worries! Through the magic of TV... boom! This is what the body of that request looks like.

Weird, right! Each field is separated by this mysterious WebkitFormBoundary thing... which is the string that we saw in the Content-Type header! Our form only has one field, but if we had multiple, this separator would be between every field. Our browsers invents this string, separates each piece of data with it, then sends this separator up with the request so that the server knows how to parse everything.

Why is this cool? Because we can now send up multiple pieces of information about our name="image" field, like the original filename on our system and what type of file it is... which, by the way, can be totally faked by the user. More on that later. After all that, we've got the data itself!

If you look all the way at the bottom, it has another WebKitFormBoundary line. If there were more fields on this form, you'd see their data below - all separated by another "boundary".

So... that's it! It literally tells our browser to send the data in a different format - and PHP understands both formats just fine. We need this format when doing file uploads because a file upload is more than just its contents: we also want to send some metadata. And also, due to how the data is encoded, if you were able to send binary data on a normal request - without the multipart/form-data encoding - it would increase the amount of data you need to upload by as much as three times! Not great for uploads!

The UploadedFile Object

Once the data arrives at the server, PHP automatically reads in the file and saves it to a temporary location on your server. Symfony then takes all of these details and puts it into a nice, neat UploadedFile object. You can see the originalName: astronaut.jpeg, the mimeType and - importantly - the location on the filesystem where the file is temporarily stored.

If we do nothing with that file, PHP will automatically delete it at the end of the request. So... our job is clear! We need to move that into a final location and... do a bunch of other things, like make sure it has a unique filename and the correct file extension. Let's handle that next.

Leave a comment!

16
Login or Register to join the conversation
Petru L. Avatar
Petru L. Avatar Petru L. | posted 2 years ago | edited

Hey there ,

I was wondering if there are any ways of retrieving some additional data from the request body, using the UploadedFile class. I'm sending the file from frontend with dropzone, which adds additional props to the file object that is sent to the backend, like a generated uuid. Is there anyway i can get that directly in backend? something like $uploadedFile->getUuid()? I've manually added that uuid as an additional param in the request body, but it feels like it's duplicated since i'm already sending the file object and i should have access to it, yet only few props are available

Reply

Hey Petru,

Hm, first of all, there's no any dynamic methods, so you can't call getUuid() method on the UploadedFile instance unfortunately. But it has getContent() method, you can try to dump the content of it to see if you have that UUID value there. Probably, with some extra steps you can decode and get that value from the content. Another possible way, I think you can create a custom class that will extends UploadedFile from core, or probably better create a wrapper for UploadedFile instance - you will pass the instance of UploadedFile to it's constructor and that class will help with extracting additional info and have methods like getUuid().

Or, try to get the current Request object and look at the values it has. All additional information about the request should be there. I think dumping the request with dd($request) should show all the information it has. Then, after you will know where that extra info is stored in the Request object - you will know how to get that extra data from the request.

I hope this helps!

Cheers!

Reply
Default user avatar
Default user avatar Arwa Alblooshi | posted 2 years ago

Hi. how to add an authorization header? thanks

Reply

Hey Arwa,

It depends on the implementation you have on your server side. In this video we're authorized and information about the current logged in user is in session. That's why we don't need to send any authorization headers. But if you're talking about not using sessions - you probably does not upload the file via usual HTML form. If you send it via the Curl or via some apps like Postman - check the docs how to add headers there. But still, you need to know how your server side works to know what exactly headers to add.

I hope this helps.

Cheers!

Reply

Hello team! I have seen this tutorial I think a bit late, and well I think I started at the end... or not?! ... Is this tutorial applicable to API Platform context? In other words, the way to upload files in API Platform can be done the same as in this tutorial? Because I have been reading the API Platform documentation and I have seen that it is quite different, I have also been watching this tutorial and I have not found a way to use it correctly in the context of API Platform, also I have not found the correct way to make Fixtures in the same context. Is this part of another tutorial or is it that I have not understood this part well yet? I would appreciate a help, Thanks in advance!!

Reply

Hey Toshiro!

Sorry for the slow reply :). Excellent question! As you can see from the API Platform docs, handling uploads in API Platform is really a custom thing. And, to a point, I think the correct implementation depends on your exact situation. First, they advocate to use VichUploaderBundle. That's a find bundle, but I personally don't use it (it feels "heavy" to me), but there is absolutely nothing wrong with it.

Could you describe your use-case a bit so I can recommend an approach? The example in API Platform - which uses a custom controller - is a bit ugly, but it works well. And it can be used with or without Vich. If you don't use Vich, then you would basically do what we do in this tutorial inside that custom controller. Another option, depending on your situation, is to leave the "post" operation alone (don't override it and give it a custom controller) but instead to add a *new* operation (e.g. "uploadImage") that handles only the upload (and then it would have a custom controller). This is a nice way to organize things, but it would mean that you would need to API calls to fully populate your object - e.g. a POST to /api/products (to use products as an example) to send all the product data and then another POST to /api/products/image to send just the file upload. Sometimes this is not feasible for whoever/whatever is using your API, but sometimes this is *desired*, as you can easily 2 both actions (editing data and the file upload) independently.

Anyways, I'm not sure I've answered your question or not yet - let me know ;). I am not convinced that there is one approach that is always best. And one thing I don't like about the API Platform solution is that, if you override the "post" operation with a custom controller, then it doesn't work if you have an API resource that holds other fields (like Product above, where you also want to send fields like title, price, etc in JSON, but also have an upload field). That's why separating into 2 custom controllers is a good option for that case.

Cheers!

Reply

Hello weaverryan thanks for the reply! This is how I imagined it would be ... It's good to see that I was not far from the truth. Well my use case is the following: I have services on my site that each have an image, but several services can have the same image so it would be a many-to-one relationship of service to MediaObject, as well as the business model can grow later, it is possible that other entities also have other relationships with my MediaObject entity (this is thinking about the future), so I have a public and a private filesystem with the OneupFlysystemBundle because some of those future relationships may be private. The VichBundle documentation has not really helped me much, in terms of how to annotate the entities to be able to cascade "persist" the images in the POST operation for example of the Service entity, so I decided to take a risk and use other variants, for example a DataPersister and a DataProvider (for fill the contentUrl field), or maybe DTOs for these entities ... for now I'm testing. What do you think is the best variant? I came up with these solutions thinking that when I do a GET to /api/services the contentUrl field will be embedded in the json of each of the services and when I made the first DataProvider for MediaObject I realized that it did not fill the contentUrl field when I made this request ... I imagined that this DataProvider was not called, now I am implementing a DataProvider for Services to fill the contentUrl field in the GET requests that I make to the Service entity. By the way, the same thing happened to me when I created a user through another entity, for example I have an Appointment entity and an anonymous user can create an appointment through the POST operation of this entity, and the way I seek to solve this problem was to create it a temporary user (who has not been registered yet), that user needs a password but when I persisted it in a normal cascade (letting doctrine do its normal work), it did not encrypt the password field so.... I had to create a DataPersister for that entity also to encrypt the password there... When I finish any of the solutions and it is working correctly and above all "have no doubts about it" I will share it here since I really have not found any example of this anywhere!. Thanks for your help Ryan I'm learning like A LOT!!! thanks to all the team!!!

Reply

Hey Toshiro!

Sorry again for my slow reply!

What do you think is the best variant? I came up with these solutions thinking that when I do a GET to /api/services the contentUrl field will be embedded in the json of each of the services

I DO like the idea of being able to make a GET request to /api/services, and having a contentUrl property there, even if in reality, the Service has a ManyToOne to MediaObject and THAT holds the information about where the image is stored :). It's nice to me (but subjective) then returning an embedded "media" field with another embedded "contentUrl" field. But, that just depends on your taste, and if there were other important fields on MediaObject that you wanted to expose, then it makes more sense :).

You mentioned that the data provider was not called on MediaObject when you make a request to /api/services. That is correct. The data provider is only called to "fetch" the "top-level" resource. In this case, the "Service" object. Once that has been fetched, even if you exposed a "mediaObject" field, API Platform simply calls $service->getMediaObject() to fetch that item. There is no data provider involved. We talk a bit about that here - https://symfonycasts.com/screencast/api-platform-security/filtered-collection - that's a collection property, but we talk about how the method is called directly, there is no extra query or data provider called for this. So, if you did want to add a mediaObject.contentUrl field to your service, that logic would need to live in the data provider for the Service: it would call ->getMediaObject() and then populate the contentUrl field on that object.

By the way, if you have a MediaObject relation on several entities and want to convert all of them to have some contentUrl field, you could create an interface - e.g. HasMediaObjectInterface - with a getMediaObject(): ?MediaObject method on it. Then, you could create a custom data provider that operates on all classes with this interface. The only tricky part (and this IS unnecessarily tricky currently) is that you would need to inject the entire "data provider" system and call it after you added the field so that the "normal" data provider could be called (in case you already have other custom data providers for those classes). To avoid recursion, your supports method would need to somehow know that it has already been called and return false the second time. It's kind of annoying :/.

for example I have an Appointment entity and an anonymous user can create an appointment through the POST operation of this entity, and the way I seek to solve this problem was to create it a temporary user (who has not been registered yet), that user needs a password but when I persisted it in a normal cascade (letting doctrine do its normal work), it did not encrypt the password field so.... I had to create a DataPersister for that entity also to encrypt the password there

Yep, this is the same problem with the data providers! The data persister is only called for the "top level" object that you are persisting - it's not called for any sub-items :).

Cheers and good luck!

Reply

It was really difficult to come up with a working example because the documentation is not very clear on this issue and I am not that good with api platform yet so..., weaverryan what do you think of this solution? Do you have any suggestion? I hope this help someone!! Cheers!!!

Reply

Well it seems that my solution was not uploaded correctly ... I think I'd better do an example on my Github page and share the link, it seems that it was too long ...

Reply

Hey Toshiro!

Yea... I don't see it here - sorry about that :/. I you link to something else, like GitHub, I can check it out.

Cheers!

Reply
Beis Avatar

Hi there! Can we specify the "temp" directory on the config? IN my case is "/tmp", but on the video is another. So it is configurable? Thanks!

Reply

Hey @Alex

Are you getting an error when uploading a file? I'm asking because you should not worry about the default temporary directory. Symfony uses the built-in php function that detects the temporary directory based on your OS

Cheers!

Reply
Beis Avatar

Hi!
No I'm not getting any error, just asking because that difference!
Thanks for clarifying!

Regards

1 Reply
Sargath Avatar
Sargath Avatar Sargath | posted 4 years ago

Thx Ryan! Nice stuff as always!! I really appreciate that! I have one question out of curiosity, what kind of tool you have used to dump multipart/form-data request?

Reply

Hey @CuriousPete!

I like that you were interested enough to ask that question :). I used Charles - the debugging proxy - it catches all traffic and you can see the raw body :).

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

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
        "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
    }
}
userVoice