Collections of UploadedFile

This video is available to view for members only.

Click here to Join!

Already a member?

Login


We've covered how to upload both a single image and collections of images, but so far we have only stored these images inside our database as strings. This is pretty limiting in the real world, so in this video we are going to expand on what we have already learned by accepting files.

Conceptually this is almost identical to what we have already seen. But as we have been building on the existing FOSREST API implementation, there are a few issues we need to handle that are specific to how our system is configured.

As in the previous videos there is a large element of copy / paste from the Symfony documentation, specifically the How to Handle File Uploads with Doctrine cookbook entry.

There are some changes we need to make though.

Single File Uploads

I'm not covering the single file upload variant of this for two reasons.

Firstly, conceptually it's identical to what we learned in video one. All you would need to do is swap out the string for the entity properties you see in this video or the cookbook entry and you're pretty much good to go.

Secondly, you can use this method for a single file upload. This is more flexible in the long run and doesn't tie your file (image / video / whatever) to one entity specifically.

However, if you are really trying to get that working and are struggling, please do get in touch and I will happily cover that scenario.

Code Coverage

We're keeping our set up and only expanding on what needs to change.

Our Car entity is not changing.

However, our Picture entity is changing quite a lot:

<?php

namespace CodeReview\RestBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity()
 * @ORM\Table(name="picture")
 * @ORM\HasLifecycleCallbacks
 */
class Picture
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * The picture's name
     *
     * @var string
     * @ORM\Column(name="name", type="string", length=255)
     */
    private $name;

    /**
     * The file path to the picture's file
     *
     * @var string
     * @ORM\Column(name="file_path", type="string", length=255, nullable=true)
     */
    private $filePath;

    /**
     * @var UploadedFile
     */
    private $file;

    private $temp;


    /**
     * Get id
     *
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @param string $name
     * @return Picture
     */
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

    /**
     * Sets file.
     *
     * @param UploadedFile $file
     */
    public function setFile(UploadedFile $file = null)
    {
        $this->file = $file;
        // check if we have an old image path
        if (isset($this->path)) {
            // store the old name to delete after the update
            $this->temp = $this->path;
            $this->path = null;
        } else {
            $this->path = 'initial';
        }
    }

    /**
     * Get file.
     *
     * @return UploadedFile
     */
    public function getFile()
    {
        return $this->file;
    }

    public function getAbsolutePath()
    {
        return null === $this->filePath
            ? null
            : $this->getUploadRootDir().'/'.$this->filePath;
    }

    public function getWebPath()
    {
        return null === $this->filePath
            ? null
            : $this->getUploadDir().'/'.$this->filePath;
    }

    protected function getUploadRootDir()
    {
        // the absolute directory path where uploaded
        // documents should be saved
        return __DIR__.'/../../../../web/'.$this->getUploadDir();
    }

    protected function getUploadDir()
    {
        // get rid of the __DIR__ so it doesn't screw up
        // when displaying uploaded doc/image in the view.
        return 'uploads/pictures';
    }

    /**
     * @ORM\PrePersist()
     * @ORM\PreUpdate()
     */
    public function preUpload()
    {
        if (null !== $this->getFile()) {
            // do whatever you want to generate a unique name
            $filename = sha1(uniqid(mt_rand(), true));
            $this->filePath = $filename.'.'.$this->getFile()->guessExtension();
        }
    }

    /**
     * @ORM\PostPersist()
     * @ORM\PostUpdate()
     */
    public function upload()
    {
        if (null === $this->getFile()) {
            return;
        }

        // if there is an error when moving the file, an exception will
        // be automatically thrown by move(). This will properly prevent
        // the entity from being persisted to the database on error
        $this->getFile()->move($this->getUploadRootDir(), $this->filePath);

        // check if we have an old image
        if (isset($this->temp)) {
            // delete the old image
            unlink($this->getUploadRootDir().'/'.$this->temp);
            // clear the temp image path
            $this->temp = null;
        }
        $this->file = null;
    }

    /**
     * @ORM\PostRemove()
     */
    public function removeUpload()
    {
        $file = $this->getAbsolutePath();
        if ($file) {
            unlink($file);
        }
    }
}

This is largely copy and paste as mentioned, however I have changed a few things to better suit my needs.

I prefer to have the path become filePath - it just makes things easier for me, personally, to understand.

Rather than show each of the entity changes separately as per the cookbook article, I have instead shown above my end result.

Amending the Form Type

As we didn't change the Car entity, we don't need to update the CarType.

However, we did make some changes to the Picture, so our PictureType form config also needs to change to reflect this:

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', 'text')
            ->add('file', 'file', array(
                'required' => true,
            ))
        ;
    }

Everything else stays the same. This also has a change to the way the data is POSTed in, so be sure to watch the video (06:00 onwards) if you're unsure what's happening there.

Dude, Where's My Files?

As mentioned in the video, the way this code has been designed is such that I really want to keep the Handler (FormHandler and CarHandler) implementations untouched.

But we hit on a problem.

When we POST in, our $request->request->all() array that's passed in by the CarController doesn't appear to contain our files.

Well, that's an easy one to rectify. With Symfony, our files will live inside $request->files->all(), so we need a way to merge them together, otherwise our image name and image file will end up in different arrays, and that will create problems.

Fixing this is a two step process. First, we need to sort out the array issue - merging the two arrays together so that no data is lost. Second, we need to pass this data to our form in such a way that our form is none the wiser.

We already have seen that our Handlers work, and they expect to receive an array which matches up with the object that's being compared against the form we are passing in. We could make things more specific closer to the FormHandler but that would very likely cause other parts of our application to have to adapt unnecessarily, or worse, end up with a nest of conditionals / switch statements. No thank you.

Instead, we push the change back. If it could go in the FormHandler, could it instead go in the CarHandler? Hmmm, it could. And the CarHandler expects an array, but we know the Request object contains all the data we need. Maybe we could change CarHandler::post to accept a Request object instead of the array it currently takes?

Alas, no.

Instead, push the change even further 'up'. Push it up, up, up as far as it will go. And then, wherever we can't push it up further, that's where we need to make the change.

Ultimately, we can make the change inside CarController::postCarAction.

Mostly this method stays the same, but rather than passing in $request->request->all(), instead we merge the files and request properties (both arrays) into a single array using array_replace_recursive. Now, this is exactly the sort of logic you would absolutely want to be unit tested. Don't leave this to chance. Put it inside its own service even, give it a nice name, something someone new to the project won't need to think about or get confused over (and / or experiment with for no good reason).

    $parameters = array_replace_recursive(
        $request->request->all(),
        $request->files->all()
    );

    $car = $this->getHandler()->post($parameters);

It Is Free, As In, All-Inclusive

As a nice side-effect of following the cookbook article we also fixed a potential bug whereby the file could be left orphaned on your server if the database persist failed for any reason, and negated a potential vulnerability by renaming any user uploaded images to instead use a randomly generated hash for the file name.

Top Tip

Whenever doing development this way, if you are stuck remember - the Profiler is your friend.

Any time you get a response from app_dev.php you will get the profiler URL - copy and paste this in and as long as you're using a recent version of Symfony you will be able to see the Form data, which should make identifying problems a whole lot easier.


Share This Episode

If you have found this video helpful, please consider sharing. I really appreciate it.


Episodes in this series

# Title Duration
1 Keeping it simple 04:01
2 Uploading a collection of things to a FOSRESTBundle API 11:24
3 Collections of UploadedFile 10:16
4 Collections of UploadedFile - Part 2 : Towards A Better Implementation 09:46