File Upload with API Platform and Symfony

In this guide, we will walk you through the steps of how to handle file uploads with API platform and Symfony. The official documentation on file uploads seems a bit convoluted to me and hence this article hopes to make it easy (in layman’s terms)! To many, handling File upload may seem tricky, but is really not difficult at all.

Handling File Uploads API Platform
If you are new to the API platform, it is highly recommended to go through our previous article that has the basics covered. We are going to build upon the “superheroes” example that was introduced in the aforementioned article.

The official docs makes use of the VichUploaderBundle to handle file uploads. But we are going to do without it. So if that’s something that you’d want too, follow along…


File Upload with Symfony and API Platform

There are 2 common approaches to handling file uploads –

  • 1. Uploading to an Existing Entity/Resource – This works by creating a new field (like profile_picture) in the desired entity which stores a path to the uploaded File.
  • 2. Uploading to a Dedicated Entity/Resource – This works by creating a new dedicated entity (like media.php) that contains fields to store the uploaded File path. Then this dedicated entity is linked to the desired entity.

Both approaches work well and either one or both can be used depending on your needs. We’ll look at both of them in detail.

1. Uploading to an Existing Entity/Resource

This approach should be used when you wish to link a file that is tightly coupled with an entity, for example, a profile picture or a cover image. When you want your user to have only 1 profile picture or only 1 cover image then this approach will work well.

As an example, we are going to use our superheroes entity that we introduced in our previous article. You could clone the repository and follow along. Be sure to checkout the tag “v1.0.0” to follow with us step-by-step. (i.e. git checkout -b FileUploadExistingEntity v1.0.0).

This is what our superheroes entity looks like currently. It has 5 fields/properties – id, name, slug, featured and created_at.

// src/Entity/superheroes.php
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
use App\Controller\SuperheroBySlug;

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource(
 *   normalizationContext={"groups" = {"read"}},
 *   denormalizationContext={"groups" = {"write"}},
 *   itemOperations={
 *     "get",
 *     "patch",
 *     "delete",
 *     "put",
 *     "get_by_slug" = {
 *       "method" = "GET",
 *       "path" = "/superhero/{slug}",
 *       "controller" = SuperheroBySlug::class,
 *       "read"=false,
 *       "openapi_context" = {
 *         "parameters" = {
 *           {
 *             "name" = "slug",
 *             "in" = "path",
 *             "description" = "The slug of your superhero",
 *             "type" = "string",
 *             "required" = true,
 *             "example"= "superman",
 *           },
 *         },
 *       },
 *     },
 *   }
 * )
 */
class superheroes
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     * @Groups({"read"})
     */
    private ?int $id = null;

    /**
     * @ORM\Column(length=70)
     * @Assert\NotBlank()
     * @Groups({"read", "write"})
     */
    public string $name;

    /**
     * @ORM\Column(length=70, unique=true)
     * @Assert\NotBlank()
     * @Groups({"read", "write"})
     */
    public string $slug;

    /**
     * @ORM\Column(type="boolean")
     * @Groups({"read", "write"})
     */
    public bool $featured = false;

    /**
     * @ORM\Column(type="datetime")
     * @Groups({"read"})
     */
    public ?\DateTime $created_at = null;


    /******** METHODS ********/

    public function getId()
    {
        return $this->id;
    }

    /**
     * Prepersist gets triggered on Insert
     * @ORM\PrePersist
     */
    public function updatedTimestamps()
    {
        if ($this->created_at == null) {
            $this->created_at = new \DateTime('now');
        }
    }

    public function __toString()
    {
        return $this->name;
    }

}

We want to be able to upload a file which will be a picture/cover image of the superhero. So lets update our entity to include a new property named “cover” which will hold the name of the image file.

// src/Entity/superheroes.php

use ApiPlatform\Core\Annotation\ApiProperty;

// ...

/**
* @param string $cover A cover image for this superhero
*
* @ORM\Column()
* @Groups({"read", "write"})
* @ApiProperty(
*   iri="http://schema.org/image",
*   attributes={
*     "openapi_context"={
*       "type"="string",
*     }
*   }
* )
*/
public ?string $cover = null;

/******** METHODS ********/

At this point you can go ahead and update your database to synchronize the entity with the database

# To check the raw sql query before executing
php bin/console doctrine:schema:update --dump-sql
 
# To execute the SQL query
php bin/console doctrine:schema:update --force
# output: [OK] Database schema updated successfully! 

# OR if you use the provided source code, 
# the following does the same thing as above
make checkdb  # prints raw sql queries to be executed
make syncdb   # executes the queries

This will create a new field “cover” in your database. If you head to your api documentation /api and try the POST endpoint, you can see that you are able populate the cover field with any string, but (as expected) no file uploading takes place since we haven’t setup file uploading yet.


Handling File Upload in Symfony

To upload files, we’re going to use a Service as described in Symfony’s official docs.
We will create the service App\Service\FileUploader.php shortly. But, before creating it, we will define the location where we wish to store the uploaded files. We do this before because our service needs to know about this location. So, update your services.yaml as shown below –

# config/services.yaml

parameters:
    public_directory: '%kernel.project_dir%/public'
    uploads_directory: '%public_directory%/uploads'

services:
    _defaults:
        # ...

        bind:               # makes $publicPath available to all services
            $publicPath: '%public_directory%'

    App\Service\FileUploader:
        arguments:
            $uploadPath: '%uploads_directory%'

As seen, we have set the location /public/uploads to be the destination of all our uploaded files. We inject the $uploadPath by specifying it as an argument to the FileUploader service. Also, we bind the variable $publicPath so it becomes available to all services. ($publicPath will be used to calculate the relative path of the uploaded files)

The main job of this service is to generate a new safe and unique filename, move the file to the desired directory and finally return the new filename. It also has helper methods that perform the task of returning an absolute/relative path of an uploaded asset. Lets go ahead and define our service now.

// src/Service/FileUploader.php
<?php

namespace App\Service;

use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\HttpFoundation\UrlHelper;

class FileUploader
{
    private $uploadPath;
    private $slugger;
    private $urlHelper;
    private $relativeUploadsDir;

    public function __construct($publicPath, $uploadPath, SluggerInterface $slugger, UrlHelper $urlHelper)
    {
        $this->uploadPath = $uploadPath;
        $this->slugger = $slugger;
        $this->urlHelper = $urlHelper;

        // get uploads directory relative to public path //  "/uploads/"
        $this->relativeUploadsDir = str_replace($publicPath, '', $this->uploadPath).'/';
    }

    public function upload(UploadedFile $file)
    {
        $originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
        $safeFilename = $this->slugger->slug($originalFilename);
        $fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension();

        try {
            $file->move($this->getuploadPath(), $fileName);
        } catch (FileException $e) {
            // ... handle exception if something happens during file upload
        }

        return $fileName;
    }

    public function getuploadPath()
    {
        return $this->uploadPath;
    }

    public function getUrl(?string $fileName, bool $absolute = true)
    {
        if (empty($fileName)) return null;

        if ($absolute) {
            return $this->urlHelper->getAbsoluteUrl($this->relativeUploadsDir.$fileName);
        }

        return $this->urlHelper->getRelativePath($this->relativeUploadsDir.$fileName);
    }
}

As you might have noticed, our service makes use of a guessExtension(); method that guesses the extension of the uploaded file. This functionality comes with the symfony/mime component. So we will have to install this component to ensure our service works correctly.

# install the mime component
composer require symfony/mime

Note: You may also want to update your .gitignore file to exclude the uploaded files from being tracked by git.

# .gitignore

/public/uploads/

Now to the main event of posting the file correctly.


Accepting File via the API

We need to modify our entity such that it is able to handle an uploaded file. For this we will be using a custom operation and a custom controller. We need to POST the file to upload it. In other words, we will have to modify the API’s collectionOperations since “POST” is a collectionOperation.

Let’s do that…

// src/Entity/superheroes.php

use App\Controller\SuperheroCoverController;
// ...

/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource(
 *   normalizationContext={"groups" = {"read"}},
 *   denormalizationContext={"groups" = {"write"}},
 *   collectionOperations={
 *     "get",
 *     "post" = {
 *       "controller" = SuperheroCoverController::class,
 *       "deserialize" = false,
 *     },
 *   },
 *   itemOperations={
 *     ...
 *   }
 * )
 */
class superheroes

As seen above, we modify the “post” collectionOperation to make the specified controller handle the post endpoint. This controller does not exist yet and we will create it shortly.

We set "deserialize" = false because API platform does not support deserialization of Form data. In other words, it does not support data encoded in application/x-www-form-urlencoded format. API platform supports JSON/JSON-LD/html by default, so theoretically, you can base64 encode your files before sending them and then decode them later, but that not only increases file size by ~30% but also adds the overhead of encoding/decoding data. That was not a viable option for us, especially if we wish to handle large file uploads. Hence, to send/receive files, we are obliged to use the multipart/form-data format.

To add support for this format, we update api_platform.yaml file as follows –

# config/packages/api_platform.yaml

api_platform:
    # ... 
    formats:
        json: ['application/json']
        jsonld: ['application/ld+json']
        html: ['text/html']
        multipart: ['multipart/form-data']

We list all available formats (including those supported by API-platform by default). Note that we only listed support for the multipart/form-data format but we still have to handle deserialization of this format.

To do so, we have 2 options:

Having decided that, lets create the controller…

// src/Controller/SuperheroCoverController.php
<?php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use App\Entity\superheroes;
use App\Service\FileUploader;

#[AsController]
final class SuperheroCoverController extends AbstractController
{
    public function __invoke(Request $request, FileUploader $fileUploader): superheroes
    {
        $uploadedFile = $request->files->get('file');
        if (!$uploadedFile) {
            throw new BadRequestHttpException('"file" is required');
        }

        // create a new entity and set its values
        $superhero = new superheroes();
        $superhero->name = $request->get('name');
        $superhero->slug = $request->get('slug');
        $superhero->featured = $request->get('featured');
        $superhero->created_at = $request->get('created_at');

        // upload the file and save its filename
        $superhero->cover = $fileUploader->upload($uploadedFile);

        return $superhero;
    }
}

Our controller retrieves the uploaded file and moves it into the destination directory with the help of the FileUploader service we created earlier. It then creates a new instance of our superheroes entity, populates it with the passed data and then returns this new instance. After this, it passes through all the usual event handlers of the API platform which ensures persistence of this new instance into the database before finally returning the response to the client.

Note: It is recommended that you validate the data received in the controller to prevent CSRF attacks.


At this point, our modified POST endpoint is ready to be tested. You will however not be able to test it yet using the swagger docs (/api) because a little more tinkering is needed to enable file uploading from swagger’s interface. We will show you how to do so shortly, in the mean time, feel free to use curl to test your endpoint.

curl -F "name=Tony stark" -F "slug=iron-man" -F "file=@/home/testuser/Downloads/ironman.jpg" -F "featured=true" http://localhost:8000/api/superheroes

# response
# {"@context":"\/api\/contexts\/superheroes","@id":"\/api\/superheroes\/2","@type":"superheroes","id":2,"name":"Tony stark","slug":"iron-man","featured":true,"created_at":"2021-08-04T15:36:52+02:00","cover":"ironman-610a97f4a195a.jpg"}

To be able to test File upload using the interface available within your documentation, you must update the openapi_context of your “superheroes” entity as shown :

// src/Entity/superheroes.php

// ...
/**
 * @ORM\Entity
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Table(name="superheroes")
 * @ApiResource(
 *   normalizationContext={"groups" = {"read"}},
 *   denormalizationContext={"groups" = {"write"}},
 *   collectionOperations={
 *     "get",
 *     "post" = {
 *       "controller" = SuperheroCoverController::class,
 *       "deserialize" = false,
 *       "openapi_context" = {
 *         "requestBody" = {
 *           "description" = "File upload to an existing resource (superheroes)",
 *           "required" = true,
 *           "content" = {
 *             "multipart/form-data" = {
 *               "schema" = {
 *                 "type" = "object",
 *                 "properties" = {
 *                   "name" = {
 *                     "description" = "The name of the superhero",
 *                     "type" = "string",
 *                     "example" = "Clark Kent",
 *                   },
 *                   "slug" = {
 *                     "description" = "The slug of the superhero",
 *                     "type" = "string",
 *                     "example" = "superman",
 *                   },
 *                   "featured" = {
 *                     "description" = "Whether this superhero should be featured or not",
 *                     "type" = "boolean",
 *                   },
 *                   "file" = {
 *                     "type" = "string",
 *                     "format" = "binary",
 *                     "description" = "Upload a cover image of the superhero",
 *                   },
 *                 },
 *               },
 *             },
 *           },
 *         },
 *       },
 *     },
 *   },
 *   itemOperations={
 *     ...
 *   }
 * )
 */
class superheroes

The above code being pretty self-explanatory, you could head over to your Swagger/OpenAPI documentation (/api) and try out the “POST” API endpoint. You should be able to create a new superhero record.

File upload API platform dedicated resource

Hitting the “Execute” button will not only create a new record, but will also upload the file to the desired location. A screenshot to go along proving that it indeed works!

Api Platform File upload success

Well, there you have it! A functional File Upload with API platform and Symfony. Let’s go a bit further and try to retrieve the cover image.


Returning an Absolute/Relative Cover URL

Lets say, you call the GET /api/superheroes/{id} API endpoint expecting the cover image. The response you’d receive would be something like this –
API platform Get cover image

As you may perceive, the value of the “cover” field isn’t very useful. It only returns a filename. But to display the cover image, we’d need either an absolute URL or a relative URL.

To fix this, we write a custom Normalizer (SuperheroNormalizer.php) which will modify the data before being returned to the client. In this normalizer, we inject the FileUploader service as it has a helper method (getUrl()) capable of generating an absolute or a relative URL. Then, we update the cover property as needed.

// src/Serializer/SuperheroNormalizer.php
<?php

namespace App\Serializer;

use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use App\Service\FileUploader;
use App\Entity\superheroes;

final class SuperheroNormalizer implements ContextAwareNormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    private FileUploader $fileUploader;
    private const ALREADY_CALLED = 'SUPERHEROES_OBJECT_NORMALIZER_ALREADY_CALLED';

    public function __construct(FileUploader $fileUploader) {
        $this->fileUploader = $fileUploader;
    }

    public function supportsNormalization($data, ?string $format = null, array $context = []): bool {
        return !isset($context[self::ALREADY_CALLED]) && $data instanceof superheroes;
    }

    public function normalize($object, ?string $format = null, array $context = []) {
        $context[self::ALREADY_CALLED] = true;

        // update the cover with the url
        $object->cover = $this->fileUploader->getUrl($object->cover);

        return $this->normalizer->normalize($object, $format, $context);
    }
}

Having done that, if you try the GET /api/superheroes/{id} API endpoint again, you should see an absolute URL to the asset being returned. And if you visit the URL, you will also see the image that you uploaded.

API platform file upload absolute asset url

And Viola! we have come to the end of first part of this guide. If you wish to test the code until here, feel free to checkout the tag v1.1.0 or this commit. Next, we’ll see how to handle file uploads with a dedicated entity/resource.


2. Uploading to a Dedicated Entity/Resource

This approach should be used when you wish to link a file that can be used with any entity once or multiple times, for example, an image of a badge. When you want a user to have one or more badges or the same badges (images), then this approach will work well.

To give a quick gist of how this works – we create a new entity (media) which will store information about uploaded files. Other entities which need to have some media associated with them, will simply link to this media entity.


To demonstrate this approach, first, we create a new entity media.php which will be a dedicated resource/entity for file uploads. For now, we define only 2 properties – id and filePath, but you are free to add any additional properties as needed –

  
// src/Entity/media.php

<?php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use App\Entity\media;
use App\Controller\MediaController;

/**
 * @ORM\Entity
 * @ORM\Table(name="media")
 * @ApiResource(
 *   iri="http://schema.org/ImageObject",
 *   normalizationContext={"groups" = {"read"}},
 *   collectionOperations={
 *     "get",
 *     "post" = {
 *       "controller" = MediaController::class,
 *       "deserialize" = false,
 *       "openapi_context" = {
 *         "requestBody" = {
 *           "description" = "File Upload",
 *           "required" = true,
 *           "content" = {
 *             "multipart/form-data" = {
 *               "schema" = {
 *                 "type" = "object",
 *                 "properties" = {
 *                   "file" = {
 *                     "type" = "string",
 *                     "format" = "binary",
 *                     "description" = "File to be uploaded",
 *                   },
 *                 },
 *               },
 *             },
 *           },
 *         },
 *       },
 *     },
 *   },
 *   itemOperations={"get", "delete"}
 * )
 */
class media
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     * @ORM\Id
     * @Groups({"read"})
     */
    private ?int $id = null;

    /**
     * @ORM\Column(nullable=true)
     * @ApiProperty(iri="http://schema.org/contentUrl")
     * @Groups({"read"})
     */
    public ?string $filePath = null;

    public function getId(): ?int
    {
        return $this->id;
    }
}

As seen, we update the “POST” collectionOperation since we want to “POST” a file. We set "deserialize" = false because API platform neither supports form data nor is capable of automatic deserialization of form-data. We handle deserialization ourselves in a controller (MediaController). The “openapi_context” is modified to support testing multipart-formdata from the API documentation/Swagger interface.

Coming back to the controller, this is what it looks like –

// src/Controller/MediaController.php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use App\Entity\media;
use App\Service\FileUploader;

#[AsController]
final class MediaController extends AbstractController
{
    public function __invoke(Request $request, FileUploader $fileUploader)
    {
        $uploadedFile = $request->files->get('file');
        if (!$uploadedFile) {
            throw new BadRequestHttpException('"file" is required');
        }

        $mediaObject = new media();
        $mediaObject->filePath = $fileUploader->upload($uploadedFile);

        return $mediaObject;
    }
}

This controller uses a service named FileUploader which is responsible for handling File Upload. This service moves the uploaded file into the required destination directory and finally returns a new unique filename. It is the same service as the one we used in the first part of the tutorial, but for the sake of completeness, here it is (again) –

// src/Service/FileUploader.php
<?php

namespace App\Service;

use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\HttpFoundation\UrlHelper;

class FileUploader
{
    private $uploadPath;
    private $slugger;
    private $urlHelper;
    private $relativeUploadsDir;

    public function __construct($publicPath, $uploadPath, SluggerInterface $slugger, UrlHelper $urlHelper)
    {
        $this->uploadPath = $uploadPath;
        $this->slugger = $slugger;
        $this->urlHelper = $urlHelper;

        // get uploads directory relative to public path //  "/uploads/"
        $this->relativeUploadsDir = str_replace($publicPath, '', $this->uploadPath).'/';
    }

    public function upload(UploadedFile $file)
    {
        $originalFilename = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
        $safeFilename = $this->slugger->slug($originalFilename);
        $fileName = $safeFilename.'-'.uniqid().'.'.$file->guessExtension();

        try {
            $file->move($this->getuploadPath(), $fileName);
        } catch (FileException $e) {
            // ... handle exception if something happens during file upload
        }

        return $fileName;
    }

    public function getuploadPath()
    {
        return $this->uploadPath;
    }

    public function getUrl(?string $fileName, bool $absolute = true)
    {
        if (empty($fileName)) return null;

        if ($absolute) {
            return $this->urlHelper->getAbsoluteUrl($this->relativeUploadsDir.$fileName);
        }

        return $this->urlHelper->getRelativePath($this->relativeUploadsDir.$fileName);
    }
}

This service also has a few helper methods like getUrl() that we could use later. The important thing to note is that the constructor expects 2 variables $publicPath and the $uploadPath to be injected, lets provide these by modifying the services.yaml file.

# config/services.yaml

parameters:
    public_directory: '%kernel.project_dir%/public'
    uploads_directory: '%public_directory%/uploads'

services:
    _defaults:
        # ...

        bind:               # makes $publicPath available to all services
            $publicPath: '%public_directory%'

    App\Service\FileUploader:
        arguments:
            $uploadPath: '%uploads_directory%'

So far, we have a new dedicated resource (media.php) to store the uploaded file’s details, a service and a controller which handles file-upload and deserialization respectively. We’re ready to test it out. Before testing, ensure to have installed the MIME component (required by the FileUploader service), synchronize your database and lastly refresh the cache.

##### install the mime component #####
composer require symfony/mime

##### update Database #####
# To check the raw sql query before executing
php bin/console doctrine:schema:update --dump-sql
  
# To execute the SQL query
php bin/console doctrine:schema:update --force
# output: [OK] Database schema updated successfully! 
 
# OR if you use the provided source code, 
# the following does the same thing as above
make checkdb  # prints raw sql queries to be executed
make syncdb   # executes the queries

#### Refresh Cache #####
php bin/console cache:clear --no-warmup --env=dev
php bin/console cache:warmup --env=dev
 
# or if you're using the provided source-code
make refresh

That’s it. Head to your documentation page /api to see the media resource with 4 endpoints –
File upload Media entity API platform

If you checkout the “POST” endpoint, you will be able to upload a file right from the interface. On trying it, you should see a similar result –

Media entity file upload result

You could also verify your uploads directory and the database to ensure that everything went as planned.

File Upload Dedicated Entity Proof

If you notice the response of the “POST” endpoint, you will see that the value of filePath is actually only the File Name and not the path. Similarly, if you test a “GET” endpoint, you will get still get a response that contains only the File Name in the filePath field. In order to be able to use the uploaded asset, we need to know its path i.e. its absolute/relative URL. So to expose the path, we will need a custom normalizer that returns the path.

We create a MediaNormalizer.php that will update the value of filePath so that it will contain the absolute URL of the uploaded image. It uses the helper method getUrl() of our FileUploader Service to accomplish this.

// src/Serializer/MediaNormalizer.php
<?php

namespace App\Serializer;

use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use App\Service\FileUploader;
use App\Entity\media;

final class MediaNormalizer implements ContextAwareNormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    private FileUploader $fileUploader;
    private const ALREADY_CALLED = 'MEDIA_OBJECT_NORMALIZER_ALREADY_CALLED';

    public function __construct(FileUploader $fileUploader) {
        $this->fileUploader = $fileUploader;
    }

    public function supportsNormalization($data, ?string $format = null, array $context = []): bool {
        return !isset($context[self::ALREADY_CALLED]) && $data instanceof media;
    }

    public function normalize($object, ?string $format = null, array $context = []) {
        $context[self::ALREADY_CALLED] = true;

        // update the filePath with the url
        $object->filePath = $this->fileUploader->getUrl($object->filePath);

        return $this->normalizer->normalize($object, $format, $context);
    }
}

Now, once again test the “GET”/”POST” endpoint of the media resource, you should see the absolute URL as expected.

File upload dedicated resource response absolute URL

Until here, we have a completely functional file upload with Symfony and API platform. Now the next easy bit is to link a record of this media entity with another entity.


Link an Entity to the Media Entity

To illustrate this, we will make use of another entity named “weapons” that will contain 3 properties – id, name, image

// src/Entity/weapons.php
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiProperty;

/**
 * @ORM\Entity
 * @ORM\Table(name="weapons")
 * @ApiResource()
 */
class weapons
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    private ?int $id = null;

    /**
     * @ORM\Column(length=70)
     * @Assert\NotBlank()
     */
    public string $name;

    /**
     * @param media $img An image for this weapon
     *
     * @ORM\OneToOne(targetEntity="media", cascade={"persist", "remove"})
     * @ApiProperty(iri="http://schema.org/image")
     */
    public ?media $image = null;


    /******** METHODS ********/

    public function getId()
    {
        return $this->id;
    }

    public function __toString()
    {
        return $this->name;
    }

}

The most important thing to note here is that the $image property has a OneToOne mapping with the media entity. i.e. One weapon will have one image. You can use any other mapping you’d like too.

Now that the entity is ready, you can sync your database as you did earlier, and then head to your documentation page to see the 6 new endpoints exposed by default –

Weapons entity API platform

If you try out the “POST” endpoint, by supplying the value for the image field as the ID of the media entity, you will get an error –

API platform upload by id error

This is quite normal because, by default, API platform expects an IRI. So lets try it again with an IRI
(i.e. image: "api/media/1")

API platform file upload by IRI

But what if you wanted to use an ID instead of an IRI. This is possible with the JSON format but its not enabled by default. We update API platform’s configuration to enable it

# config/packages/api_platform.yaml

api_platform:
    # ...
    allow_plain_identifiers: true

Now if you retry by using application/json format and providing an ID as the value of image, you should get the desired response. (If it doesn’t work in the first go, try refreshing your cache)

API platform File upload with Identifier

Well, that’s about it. We successfully linked the weapons entity with the media entity. You could optionally update the swagger documentation to make it more clearer. This is shown in the source code available on github. Be sure to checkout the tag v1.2.0 (i.e. git checkout -b FileUploadDedicatedEntity v1.2.0)to see everything done until now in action.

Hope that helps 🙂


References

1 thought on “File Upload with API Platform and Symfony”

Leave a Reply