# Handling File Upload with Symfony As common a problem as it may seem, handling file upload requires a custom implementation in your app. This page will guide you in handling file upload in your API, with the help of[VichUploaderBundle](https://github.com/dustin10/VichUploaderBundle). It is recommended you [read the documentation of VichUploaderBundle](https://github.com/dustin10/VichUploaderBundle/blob/master/docs/index.md) before proceeding. It will help you get a grasp on how the bundle works, and why we use it. > [!NOTE] > Uploading files won't work in `PUT` or `PATCH` requests, you must use `POST` method to upload files. > See [the related issue on Symfony](https://github.com/symfony/symfony/issues/9226) and > [the related bug in PHP](https://bugs.php.net/bug.php?id=55815) talking about this behavior. Enable the multipart format globally in order to use it as the input format of your resource: ```yaml api_platform: formats: jsonld: ['application/ld+json'] multipart: ['multipart/form-data'] ``` ## Installing VichUploaderBundle Install the bundle with the help of Composer: ```console composer require vich/uploader-bundle ``` This will create a new configuration file that you will need to slightly change to make it look like this. ```yaml # api/config/packages/vich_uploader.yaml vich_uploader: db_driver: orm metadata: type: attribute mappings: media_object: uri_prefix: /media upload_destination: '%kernel.project_dir%/public/media' # Will rename uploaded files using a uniqueid as a suffix. namer: Vich\UploaderBundle\Naming\SmartUniqueNamer ``` ## Uploading to a Dedicated Resource In this example, we will create a `MediaObject` API resource. We will post files to this resource endpoint, and then link the newly created resource to another resource (in our case: `Book`). ### Configuring the Resource Receiving the Uploaded File The `MediaObject` resource is implemented like this: ```php ['media_object:read']], types: ['https://schema.org/MediaObject'], outputFormats: ['jsonld' => ['application/ld+json']], operations: [ new Get(), new GetCollection(), new Post( inputFormats: ['multipart' => ['multipart/form-data']], openapi: new Model\Operation( requestBody: new Model\RequestBody( content: new \ArrayObject([ 'multipart/form-data' => [ 'schema' => [ 'type' => 'object', 'properties' => [ 'file' => [ 'type' => 'string', 'format' => 'binary' ] ] ] ] ]) ) ) ) ] )] class MediaObject { #[ORM\Id, ORM\Column, ORM\GeneratedValue] private ?int $id = null; #[ApiProperty(types: ['https://schema.org/contentUrl'], writable: false)] #[Groups(['media_object:read'])] public ?string $contentUrl = null; #[Vich\UploadableField(mapping: 'media_object', fileNameProperty: 'filePath')] #[Assert\NotNull] public ?File $file = null; #[ApiProperty(writable: false)] #[ORM\Column(nullable: true)] public ?string $filePath = null; public function getId(): ?int { return $this->id; } } ``` Note: From V3.3 onwards, `'multipart/form-data'` must either be including in the global API-Platform config, either in `formats` or `defaults->inputFormats`, or defined as an `inputFormats` parameter on an operation by operation basis. ### Resolving the File URL Returning the plain file path on the filesystem where the file is stored is not useful for the client, which needs a URL to work with. A [normalizer](../core/serialization.md#normalization) could be used to set the `contentUrl` property: ```php contentUrl = $this->storage->resolveUri($object, 'file'); return $this->normalizer->normalize($object, $format, $context); } public function supportsNormalization($data, ?string $format = null, array $context = []): bool { if (isset($context[self::ALREADY_CALLED])) { return false; } return $data instanceof MediaObject; } public function getSupportedTypes(?string $format): array { return [ MediaObject::class => true, ]; } } ``` ### Making a Request to the `/media_objects` Endpoint Your `/media_objects` endpoint is now ready to receive a `POST` request with a file. This endpoint accepts standard `multipart/form-data`-encoded data, but not JSON data. You will need to format your request accordingly. After posting your data, you will get a response looking like this: ```json { "@type": "https://schema.org/MediaObject", "@id": "/media_objects/", "contentUrl": "" } ``` ### Accessing Your Media Objects Directly You will need to modify your `Caddyfile` to allow the above `contentUrl` to be accessed directly. If you followed the above configuration for the VichUploaderBundle, that will be in `api/public/media`. Add your folder to the list of path matches, e.g. `|^/media/|`: ```patch # Matches requests for HTML documents, for static files and for Next.js files, # except for known API paths and paths with extensions handled by API Platform @pwa expression `( header({'Accept': '*text/html*'}) && !path( - '/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*', + '/media*', '/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*', '*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml' ) ) || path('/favicon.ico', '/manifest.json', '/robots.txt', '/_next*', '/sitemap*')` ``` ### Linking a MediaObject Resource to Another Resource We now need to update our `Book` resource, so that we can link a `MediaObject` to serve as the book cover. We first need to edit our Book resource, and add a new property called `image`. ```php " } ``` VoilĂ ! You can now send files to your API, and link them to any other resource in your app. ### Testing To test your upload with `ApiTestCase`, you can write a method as below: ```php request('POST', 'http://localhost:8888/api/media_objects', [ 'headers' => ['Content-Type' => 'multipart/form-data'], 'extra' => [ // If you have additional fields in your MediaObject entity, use the parameters. 'parameters' => [ // 'title' => 'title' ], 'files' => [ 'file' => $file, ], ] ]); $this->assertResponseIsSuccessful(); $this->assertMatchesResourceItemJsonSchema(MediaObject::class); $this->assertJsonContains([ // 'title' => 'My file uploaded', ]); } } ``` ## Uploading to an Existing Resource with its Fields In this example, the file will be included in an existing resource (in our case: `Book`). The file and the resource fields will be posted to the resource endpoint. This example will use a custom `multipart/form-data` decoder to deserialize the resource instead of a custom controller. > [!WARNING] > Make sure to encode the fields in JSON before sending them. For instance, you could do something like this: ```js async function uploadBook(file) { const bookMetadata = { title: "API Platform Best Practices", genre: "Programming" }; const formData = new FormData(); for (const [name, value] of Object.entries(bookMetadata)) { formData.append(name, JSON.stringify(value)); } formData.append('file', file); const response = await fetch('https://my-api.com/books', { method: 'POST', body: formData }); const result = await response.json(); return result; } ``` ### Configuring the Existing Resource Receiving the Uploaded File The `Book` resource needs to be modified like this: ```php ['book:read']], denormalizationContext: ['groups' => ['book:write']], types: ['https://schema.org/Book'], operations: [ new GetCollection(), new Post( outputFormats: ['jsonld' => ['application/ld+json']], inputFormats: ['multipart' => ['multipart/form-data']] ) ] )] class Book { // ... #[ApiProperty(types: ['https://schema.org/contentUrl'])] #[Groups(['book:read'])] public ?string $contentUrl = null; #[Vich\UploadableField( mapping: 'media_object', fileNameProperty: 'filePath', )] #[Groups(['book:write'])] public ?File $file = null; #[ORM\Column(nullable: true)] public ?string $filePath = null; // ... } ``` ### Handling the Multipart Deserialization By default, Symfony is not able to decode `multipart/form-data`-encoded data. We need to create our own decoder to do it: ```php requestStack->getCurrentRequest(); if (!$request) { return null; } return array_map(static function (string $element) { // Multipart form values will be encoded in JSON. return json_decode($element, true, flags: \JSON_THROW_ON_ERROR); }, $request->request->all()) + $request->files->all(); } public function supportsDecoding(string $format): bool { return self::FORMAT === $format; } } ``` If you're not using `autowiring` and `autoconfiguring`, don't forget to register the service and tag it as `serializer.encoder`. We also need to make sure the field containing the uploaded file is not denormalized: ```php true, ]; } } ``` If you're not using `autowiring` and `autoconfiguring`, don't forget to register the service and tag it as `serializer.normalizer`. For resolving the file URL, you can use a custom normalizer, like shown in [the previous example](#resolving-the-file-url).