Upload documents the right way with Symfony in AWS S3 buckets

Why?

Adding upload fields in Symfony application eases the way of managing assets. It makes it possible to upload public assets as well as sensitive documents instantly without any devops knowledge. Hence, I’ll show you a way of implementing a Symfony / Amazon AWS architecture to store your documents in the cloud.

Setup Symfony and AWS

First you need to setup both Symfony and AWS to start storing some files from Symfony in AWS buckets.

Amazon AWS

Creating a bucket on Amazon AWS is really straight forward. First you need to sign up on Amazon S3 (http://aws.amazon.com/s3). Go to the AWS console and search S3. Then click on Create a bucket.
Follow bucket creation process choosing default values (unless you purposely want to give public access to your documents, you should keep your bucket private). Eventually create a directory for each of your environments. Your AWS S3 bucket is now ready to store your documents.

Symfony

Now you need to setup Symfony to be able to store files and to communicate with Amazon AWS. You will need 2 bundles and 1 SDK to set it up:

  • VichUploader (a bundle that will ease files upload)
  • KNP/Gauffrette (a bundle that will provide an abstraction layer to use uploaded files in your Symfony application without requiring to know where those files are actually stored)
  • AWS-SDK (A SDK provided by Amazon to communicate with AWS API)

Install the two bundles and the SDK with composer:

composer require vich/uploader-bundle
composer require aws/aws-sdk-php
composer require knplabs/knp-gaufrette-bundle

Then register the bundles in AppKernel.php

public function registerBundles()
    {
     return [
            	new Vich\UploaderBundle\VichUploaderBundle(),
            	new Knp\Bundle\GaufretteBundle\KnpGaufretteBundle(),
            ];
    }

Bucket parameters

It is highly recommended to use environment variables to store your buckets parameters and credentials. It will make it possible to use different buckets depending on your environment and will prevent credentials from being stored in version control system. Hence, you won’t pollute your production buckets with test files generated in development environment.
You will need to define four parameters to get access to your AWS bucket:

  • AWS_BUCKET_NAME
  • AWS_BASE_URL
  • AWS_KEY (only for and private buckets)
  • AWS_SECRET_KEY (only for and private buckets)

You can find the values of these parameters in your AWS console.

Configuration

You will have to define a service extending Amazon AWS client and using your AWS credentials.
Add this service in services.yml:

ct_file_store.s3:
        class: Aws\S3\S3Client
        factory: [Aws\S3\S3Client, 'factory']
        arguments:
            -
                version: YOUR_AWS_S3_VERSION (to be found in AWS console depending on your bucket region and version)
                region: YOUR_AWS_S3_REGION
                credentials:
                    key: '%env(AWS_KEY)%'
                    secret: '%env(AWS_SECRET_KEY)%'

Now you need to configure VichUploader and KNP_Gaufrette in Symfony/app/config/config.yml. Use the parameters previously stored in your environment variables.
Here is a basic example:

knp_gaufrette:
    stream_wrapper: ~
    adapters:
        document_adapter:
            aws_s3:
                service_id: ct_file_store.s3
                bucket_name: '%env(AWS_BUCKET_NAME)%'
                detect_content_type: true
                options:
                    create: true
                    directory: document
    filesystems:
        document_fs:
            adapter:    document_adapter

vich_uploader:
    db_driver: orm
    storage: gaufrette
    mappings:
        document:
            inject_on_load: true
            uri_prefix: "%env(AWS_BASE_URL)%/%env(AWS_BUCKET_NAME)%/document"
            upload_destination: document_fs
            delete_on_update:   false
            delete_on_remove:   false 

Upload files

First step in our workflow is to upload a file from Symfony to AWS. You should create an entity to store your uploaded document (getters and setters are omitted for clarity, you will need to generate them).
The attribute mapping in $documentFile property annotation corresponds to the mapping defined in config.yml. Don’t forget the class attribute @Vich\Uploadable().

namespace MyBundle\Entity;

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

/**
 * Class Document
 *
 * @ORM\Table(name="document")
 * @ORM\Entity()
 * @Vich\Uploadable()
 */
class Document
{
    /**
     * @var int
     *
     * @ORM\Column(type="integer", name="id")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $documentFileName;

    /**
     * @var File
     * @Vich\UploadableField(mapping="document", fileNameProperty="documentFileName")
     */
    private $documentFile;

    /**
     * @var \DateTime
     *
     * @ORM\Column(type="datetime")
     */
    private $updatedAt;
}

Then you can add an uploaded document to any of your entities:

     /**
     * @var Document
     *
     * @ORM\OneToOne(
     *     targetEntity="\MyBundle\Entity\Document",
     *     orphanRemoval=true,
     *     cascade={"persist", "remove"},
     * )
     * @ORM\JoinColumn(name="document_file_id", referencedColumnName="id", onDelete="SET NULL")
     */
    private $myDocument;

Create a form type to be able to upload a document:

class UploadDocumentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        add('myDocument', VichFileType::class, [
                'label'         => false,
                'required'      => false,
                'allow_delete'  => false,
                'download_link' => true,
            ]);
    }
...
}

Use this form type in your controller and pass the form to the twig:

...
$myEntity = new MyEntity();
$form = $this->createForm(UploadDocumentType::class, $myEntity);
...
return [ 'form' => $form->createView()];

Finally, add this form field in your twig and you should see an upload field in your form:

<div class="row">
    <div class="col-xs-4">
        {{ form_label(form.myDocument) }}
    </div>
    <div class="col-xs-8">
        {{ form_widget(form.myDocument) }}
    </div>
    <div class="col-xs-8 col-xs-offset-4">
        {{ form_errors(form.myDocument) }}
    </div>
</div>

Navigate to your page, upload a file and submit your form. You should now be able to see this document in your AWS bucket.

Users are now able to upload files on your Symfony application and these documents are safely stored on Amazon AWS S3 bucket. The next step is to provide a way to download and display these documents from AWS in your Symfony application.

Display or download documents stored in private buckets

In most cases, your files are stored in private buckets. Here is a step by step way to safely give access to these documents to your users.

Get your document from private bucket

You will need a method to retrieve your files from your private bucket and display it on a custom route. As a result, users will never see the actual route used to download the file. You should define this method in a separate service and use it in the controller.
s3Client is the service (ct_file_store.s3) we defined previously extending AWS S3 client with credentials for private bucket. You will need to inject your bucket name from your environment variables in this service. my-documents/ is the folder you created to store your documents.

     /**
     * @param string $documentName
     *
     * @return \Aws\Result|bool
     */
    public function getDocumentFromPrivateBucket($documentName)
    {
        try {
            return $this->s3Client->getObject(
                [
                    'Bucket' => $this->privateBucketName,
                    'Key'    => 'my-documents/'.$documentName,
                ]
            );
        } catch (S3Exception $e) {
            // Handle your exception here
        }
    }

Define an action with a custom route:

You will need to use the method previously defined to download the file from AWS and expose it on a custom route.

     /**
     * @param Document $document
     * @Route("/{id}/download-document", name="download_document")
     * @return RedirectResponse|Response
     */
    public function downloadDocumentAction(Document $document)
    {
        $awsS3Uploader  = $this->get('app.service.s3_uploader');

        $result = $awsS3Uploader->getDocumentOnPrivateBucket($document->getDocumentFileName());

        if ($result) {
            // Display the object in the browser
            header("Content-Type: {$result['ContentType']}");
            echo $result['Body'];

            return new Response();
        }

        return new Response('', 404);
    }

Download document

Eventually add a download button to access a document stored in a private bucket directly in your Symfony application.

<a href="{{ path('/download-document', {'id': document.id}) }}" 
                   target="_blank">
   <i class="fa fa-print">
   {{ 'label_document_download'|trans }}
</a>

Public assets

You may want to display some assets from Amazon AWS in public pages of your application. To do so, use a public bucket to upload these assets. It is quite straight forward to access it to display them. Be conscious that anyone will be able to access these files outside your application.

<img src="{{ vich_uploader_asset(myEntity.myDocument, 'documentFile') }}" alt="" />


You liked this article? You'd probably be a good match for our ever-growing tech team at Theodo.

Join Us

  • GromNaN

    Do you know you can upload to S3 directly from the browser, without transiting by your own server?
    http://bencoe.tumblr.com/post/30685403088/browser-side-amazon-s3-uploads-using-cors

  • Alan Rauzier

    Hello GromNaN, I didn’t know, thanks for the trick! However, I am not very confortable trusting browser uploading files in private buckets without server-side validation.

  • Lukáš Holeczy

    Thanks for the great article, helped a lot! Just two things to make it better:

    1) It would be great to explain, where we should find the parameters and service arguments. For example where to find the version, if we should use the region name or code, where to create access key and secret, …
    2) In configurations you are using different parameter names than in the list above. ( env(AWS_BUCKET) vs env(AWS_BUCKET_NAME) and env(AWS_SECRET) vs env(AWS_SECRET_KEY) ).