I’m migrating a legacy PHP application to Symfony, and that includes implementing file uploads to AWS S3. Symfony does not provide this feature out of the box, and many of the existing tutorials are outdated or do not work well, therefore I decided to document this in the hope that it will be useful to someone else.

To implement this, we will need to install four packages:

  • aws/aws-sdk-php: the AWS PHP SDK.
  • oneup/flysystem-bundle: a bundle for Flysystem (league/flysystem), a filesystem abstraction library.
  • league/flysystem-aws-s3-v3: a Flysystem adapter for AWS SDK.
  • vich/uploader-bundle: a bundle that eases file uploads that are attached to ORM entities.

These packages can be easily installed with Composer:

composer require aws/aws-sdk-php oneup/flysystem-bundle league/flysystem-aws-s3-v3 vich/uploader-bundle

Next, we need to add to AppKernel.php our newly installed bundles:

<?php

class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = [
            // other bundles...
            new Vich\UploaderBundle\VichUploaderBundle(),
            new Oneup\FlysystemBundle\OneupFlysystemBundle(),
            new AppBundle\AppBundle()
        ];

        // rest of the code
    }
}

Now, we will start setting up the bundles on the configuration file app/config.yml.

First, we will setup the Flysystem bundle. This bundle will expose a set of adapters and filesystems for the application to use. The configuration below creates an adapter called assets_adapter and a filesystem called assets_fs, which in turn uses the adapter assets_adapter. The built-in awss3v3 Flysystem adapter takes two arguments: a service that’s nothing more than an instance of the AWS S3 SDK and the AWS bucket name (in this case, from the parameters.yml variable called assets_bucket).

oneup_flysystem:
    adapters:
        assets_adapter:
            awss3v3:
                client: app.assets.s3
                bucket: %assets_bucket%
                prefix: assets

    filesystems:
        assets_fs:
            adapter:    assets_adapter
            mount:      assets_fs

Next, we will setup the VichUploaderBundle. The configuration below tells VichUploaderBundle to use the orm driver, which integrates with Doctrine, and to use the Flysystem storage adapter. It also creates a mapping, which will be hooked into our entity. A mapping tells VichUploaderBundle where to upload a file, how to generate its URI, and what naming strategy to use. In this case, we are supplying a user defined URI prefix, which is the S3 bucket URI (it’s defined in parameters.yml); we are telling it to upload the file the the assets_fs filesystem; and we are telling it to use the naming strategy that generates unique IDs for each file.

vich_uploader:
    db_driver: orm
    storage:   flysystem

    mappings:
        assets:
            uri_prefix:         %assets_uri%
            upload_destination: assets_fs
            namer:              vich_uploader.namer_uniqid

Finally, on app/services.yml, we will create the AWS S3 service that will be used to upload the files to S3. The configuration below defines a service named app.assets.s3 that is an instance of the Aws\S3\S3Client class, which takes the arguments defined below. These arguments are best stored in the parameters.yml file.

services:
    app.assets.s3:
        class: Aws\S3\S3Client
        arguments:
            -
                version: 'latest'
                region: %assets_region%
                credentials:
                    key: %assets_key%
                    secret: %assets_secret%

Good! Now it’s time to plug everything into our model.

Let us suppose we have an entity called Product, which represents a… product, ta-da! First, we will start by adding the @Vich\Uploadable annotation to the class, which allows it to upload the files. Then, we will add two properties: $image, which represents an image that was uploaded, and $imageFile, which represents a image that may be uploaded. Finally, we will also add a few setters and getters. The complete class can be seen below:

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;

/**
 * Product
 *
 * @ORM\Table(name="product")
 * @Vich\Uploadable
 */
class Product
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    protected $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=64, nullable=false)
     */
    protected $name;

    /**
     * @ORM\Column(name="image", type="string", length=255, nullable=true)
     *
     * @var string
     */
    protected $image = null;

    /**
     * @Vich\UploadableField(mapping="assets", fileNameProperty="image")
     *
     * @var File
     */
    protected $imageFile;

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

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

        return $this;
    }

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

    /**
     * @param string $image
     *
     * @return Category
     */
    public function setImage($image)
    {
        $this->image = $image;

        return $this;
    }

    /**
     * @return string|null
     */
    public function getImage()
    {
        return $this->image;
    }

    /**
     * If manually uploading a file (i.e. not using Symfony Form) ensure an instance
     * of 'UploadedFile' is injected into this setter to trigger the  update. If this
     * bundle's configuration parameter 'inject_on_load' is set to 'true' this setter
     * must be able to accept an instance of 'File' as the bundle will inject one here
     * during Doctrine hydration.
     *
     * @param File|\Symfony\Component\HttpFoundation\File\UploadedFile $image
     *
     * @return Product
     */
    public function setImageFile(File $image = null)
    {
        $this->imageFile = $image;

        return $this;
    }

    /**
     * Returns the image to be uploaded.
     *
     * @return File|null
     */
    public function getImageFile()
    {
        return $this->imageFile;
    }
}

Note that the $imageFile property has the following annotation:

/**
 * @Vich\UploadableField(mapping="assets", fileNameProperty="image")
 */

This annotation tells VichUploaderBundle to upload the file using the assets mapping we defined earlier, and to use the $image property to hold the details of the uploaded file.

Now, how do we upload an image? We add an image field to the product creation form. VichUploaderBundle provides a special form type VichImageType that automatically handles the file download, file preview and deletion. We simply add it to our form and use the $imageFile property - remember, this is the property that handles the image upload.

<?php

namespace AppBundle\Form\Type;

use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\{
    AbstractType,
    FormBuilderInterface,
};
use Symfony\Component\Form\Extension\Core\Type\{
    SubmitType,
    TextType
};
use Symfony\Component\OptionsResolver\OptionsResolver;
use Vich\UploaderBundle\Form\Type\VichImageType;

/**
 * Form used to create products.
 */
class ProductCreateType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, [
                'label' => 'Nome'
            ])
            ->add('imageFile', VichImageType::class, [
                'label' => 'Image'
              , 'required' => false
            ])
            ->add('save', SubmitType::class, [
                'label' => 'Save'
            ]);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\Entity\Product'
        ]);
    }
}

What about our controller? What do we need to change? Fortunately, we don’t need to change anything: VichUploaderBundle is well implemented, and uploading is done behind the scenes when the forms are processed.

That’s all. You’re ready to start uploading. ;)

Notes

You probably do not want to upload your files to S3 on your development and test environments (maybe you do, but that’s not very smart, lol). You can get over this by using a local filesystem on dev and a in-memory filesystem on test.

// config_test.yml
oneup_flysystem:
    adapters:
        local_adapter:
            memory: ~

vich_uploader:
    db_driver: orm
    storage:   flysystem

    mappings:
        assets:
            upload_destination: local_fs
            namer:              vich_uploader.namer_uniqid
// config_dev.yml
oneup_flysystem:
    adapters:
        local_adapter:
            local:
                directory: %kernel.root_dir%/../web/uploads

    filesystems:
        local_fs:
            adapter: local_adapter
            mount:   local_fs

vich_uploader:
    db_driver: orm
    storage:   flysystem

    mappings:
        assets:
            uri_prefix:         /uploads
            upload_destination: local_fs
            namer:              vich_uploader.namer_uniqid