Uploading files in Symfony

Uploading files in Symfony** is conceptually no different from other PHP platforms, but still has its own features due to the presence of additional tools provided by the framework.

First of all, it is worth noting that there are ready-made solutions that solve the task at hand. I strongly recommend familiarizing yourself with them, and only after that, if you decide that they do not suit you, implement your own solution.

In this note we will try to show possible ways to solve the task, using both ready-made solutions (VichUploaderBundle, IphpFileStoreBundle), and using our own implementation (in Symfony controllers and admin classes from the SonataAdminBundle).

1. VichUploaderBundle

The usage is extremely simple.

Installation:

composer require vich/uploader-bundle

Add to AppKernel:

// app/AppKernel.php
class AppKernel extends Kernel
{
    public function registerBundles()
    {
        $bundles = [
            // ...
            new Vich\UploaderBundle\VichUploaderBundle(),
            // ...
        ];
    }
}

Basic configuration:

# app/config/config.yml
vich_uploader:
    db_driver: orm # or mongodb or propel or phpcr

Next, you need to configure the types of uploaded files. For example, let's configure the upload of user avatars:

# app/config/config.yml
vich_uploader:
    db_driver: orm

    mappings:
        user_avatar:
            uri_prefix:         /images/avatars
            upload_destination: '%kernel.root_dir%/../web/images/avatars'

After that, add the necessary fields to the entity:

<?php

namespace Acme\DemoBundle\Entity;

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

/**
 * @ORM\Entity
 * @Vich\Uploadable
 */
class User
{
    // ..... entity fields

    /**
     * NOTE: This is not a mapped field of entity metadata, just a simple property.
     *
     * @Vich\UploadableField(mapping="user_avatar", fileNameProperty="avatarFileName")
     *
     * @var File
     */
    private $avatarFile;

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

    /**
     * @param File|UploadedFile $avatarFile
     *
     * @return User
     */
    public function setAvatarFile(File $avatarFile = null)
    {
        $this->avatarFile = $avatarFile;

        return $this;
    }

    /**
     * @return File|null
     */
    public function getAvatarFile()
    {
        return $this->avatarFile;
    }

    /**
     * @param string $avatarFileName
     *
     * @return User
     */
    public function setAvatarFileName($avatarFileName)
    {
        $this->avatarFileName = $avatarFileName;

        return $this;
    }

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

Next, create the form and use one of the form types available in https://github.com/dustin10/VichUploaderBundle/tree/master/Form/Type

For example:

<?php

namespace Acme\DemoBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Vich\UploaderBundle\Form\Type\VichImageType;

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options = [])
    {
        $builder
           // ... other fields
            ->add('avatarFile', VichImageType::class, [
                'required' => true,
            ])
        ;
    }
}

2. IphpFileStoreBundle

Fork of the previous bundle. Key differences - improved integration with SonataAdminBundle, as well as extended saved file information (the field stores an array of metadata, not the file path).

Usage is very similar to VichUploaderBundle, for more details see the documentation.

3. Symfony

If for some reason the previous bundles did not suit you, you can implement the upload yourself.

We'll show an example using the same User as before:

  1. Entity:
<?php
// src/AppBundle/Entity/User.php
namespace AppBundle\Entity;

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

class User
{
    // ...

    /**
     * @ORM\Column(type="string")
     *
     * @Assert\NotBlank()
     * @Assert\Image()
     */
    private $avatar;

    public function getAvatar()
    {
        return $this->avatar;
    }

    public function setAvatar($avatar)
    {
        $this->avatar = $avatar;

        return $this;
    }
}
  1. Form:
<?php
// src/AppBundle/Form/Type/UserType.php
namespace AppBundle\Form\Type;

use AppBundle\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\FileType;

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->add('avatar', FileType::class)
            // ...
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => User::class,
        ));
    }
}

Next, render the form. The most interesting part is the data handling. For simplicity, we'll show how to do it in the controller (example partially borrowed from the Symfony documentation). Ideally, the file upload processing logic should be moved to a separate service. You can also move the logic into Doctrine events.

<?php
// src/AppBundle/Controller/UserController.php
namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Entity\User;
use AppBundle\Form\Type\UserType;

class UserController extends Controller
{
    /**
     * @Route("/user/register", name="app_user_new")
     */
    public function newAction(Request $request)
    {
        $form = $this->createForm(UserType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $user = $form->getData();
            // $file stores the uploaded file
            /** @var Symfony\Component\HttpFoundation\File\UploadedFile $file */
            $file = $user->getAvatar();

            // Generate a unique name for the file before saving it
            $fileName = md5(uniqid()).'.'.$file->guessExtension();

            // Move the file to the directory where user avatars are stored
            $file->move(
                // This parameter should be configured
                $this->getParameter('avatars_directory'),
                $fileName
            );

            // Update the 'avatar' property to store file name
            // instead of its contents
            $user->setAvatar($fileName);

            // ... persist the $user variable or any other work and redirect

            return $this->redirect($this->generateUrl('homepage'));
        }

        return $this->render('user/new.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

4. SonataAdminBundle

Finally, I would like to show an example of file upload in SonataAdminBundle.

The simplest option is to move the logic from the previous example to the prePersist/preUpdate hooks - it suggests itself. (If you still decide to do this option and didn't ignore the last recommendation to move the upload processing logic to a separate service - it will greatly simplify the task). (Actually, I'm cheating a little, it can be even simpler: if you move the upload logic to Doctrine events, you don't need to add any additional processing in Sonata hooks at all.)

Let's show a slightly more "advanced" option for uploading images using ckeditor and an image library. (For example, for inserting into notes, not just as an entity field).

For these purposes, let's create a Controller for Sonata:

<?php

namespace Harentius\BlogBundle\Controller;

use Sonata\AdminBundle\Controller\CRUDController as BaseCRUDController;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class CRUDController extends BaseCRUDController
{
    /**
     * @param Request $request
     * @return Response
     */
    public function uploadAction(Request $request)
    {
        $manager = $this->get('harentius_blog.assets.manager');
        $assetFile = $manager->handleUpload($request->files->get('upload'));

        return $this->render('HarentiusBlogBundle:Admin:ck_upload.html.twig', [
            'func_num' => $request->query->get('CKEditorFuncNum'),
            'uri' => $assetFile->getUri(),
        ]);
    }

    /**
     * @param Request $request
     * @param string $type
     * @return Response
     */
    public function browseAction(Request $request, $type = 'image')
    {
        if (!in_array($type, ['image', 'audio'])) {
            throw new \InvalidArgumentException(sprintf("Unknown files type '%s", $type));
        }

        $resolver = $this->get('harentius_blog.assets.resolver');
        $directory = $resolver->assetPath($type);
        $files = [];
        $finder = new Finder();
        $finder
            ->files()
            ->in($directory)
            ->ignoreDotFiles(true)
        ;

        /** @var SplFileInfo $file */
        foreach ($finder as $file) {
            $uri = $resolver->pathToUri($file->getPathname());

            if ($uri) {
                $files[$uri] = pathinfo($uri, PATHINFO_BASENAME);
            }
        }

        return $this->render(sprintf("HarentiusBlogBundle:Admin:ck_browse_%ss.html.twig", $type), [
            'func_num' => $request->query->get('CKEditorFuncNum'),
            'files' => $files,
        ]);
    }
}

The first method uses a service for file upload (see the implementation), and the second one generates a list of files for display in ckeditor.

Templates used by this controller:

{# src/Resources/views/Admin/ck_upload.html.twig #}
<script type="text/javascript">window.parent.CKEDITOR.tools.callFunction("{{ func_num }}", "{{ uri }}", "{{ message|default('') }}");</script>
{# src/Resources/views/Admin/ck_browse_images.html.twig #}

{% for uri, name in files %}
    <div class="clickable pull-left" onclick="ckEditorChoose('{{ func_num }}', '{{ uri }}')">
        <img width="300px" src="{{ uri }}" />
    </div>
{% endfor %}

<script>
    function ckEditorChoose(funcNum, uri) {
        window.opener.CKEDITOR.tools.callFunction(funcNum, uri);
        window.close();
    }
</script>

Initialization of ckeditor:

{% extends 'SonataAdminBundle:CRUD:edit.html.twig' %}

{% block javascripts %}
    {{ parent() }}
    <script src="{{ asset('assets/vendor/ckeditor/ckeditor.js') }}"></script>
    <script src="{{ asset('assets/vendor/ckeditor/adapters/jquery.js') }}"></script>

    <script>
        'use strict';

        jQuery(function ($) {

{# extra plugins, probably, you don't need all of them #}
            CKEDITOR.plugins.addExternal("youtube", "{{ asset('assets/vendor/ckeditor-youtube-plugin/youtube/plugin.js') }}");
            CKEDITOR.plugins.addExternal("wpmore", "{{ asset('assets/vendor/ckeditor-more-plugin/wpmore/plugin.js') }}");
            CKEDITOR.plugins.addExternal("audio", "{{ asset('assets/vendor/ckeditor-audio-plugin/audio/plugin.js') }}");
            CKEDITOR.plugins.addExternal("wordcount", "{{ asset('assets/vendor/ckeditor-wordcount-plugin/wordcount/plugin.js') }}");

            CKEDITOR.config.allowedContent = true;
            $('.blog-page-edit').ckeditor({
                height: 700,
                extraPlugins: "youtube,justify,div,wpmore,codesnippet,audio,image2,wordcount",
                removePlugins: 'image,forms',
                toolbarGroups: [
                    { name: "basicstyles", groups: [ "basicstyles", "cleanup" ] },
                    { name: "editing",     groups: [ "find", "selection" ] },
                    { name: "align" },
                    { name: "paragraph",   groups: [ "list", "indent", "blocks" ] },
                    { name: "links" },
                    { name: "insert" },
                    { name: "styles" },
                    { name: "colors" },
                    { name: "forms" },
                    { name: "clipboard",   groups: [ "clipboard", "undo" ] },
                    { name: "document",    groups: [ "mode", "document", "doctools" ] },
                    { name: "tools" },
                    { name: "others" }
                ],
                wordcount: {
                    showParagraphs: false
                },
{# Your point of interest: #}
                filebrowserUploadUrl: "{{ admin.generateUrl('upload') }}",
                filebrowserBrowseUrl: "{{ admin.generateUrl('browse', {'type': 'image'}) }}",
                filebrowserAudioBrowseUrl: "{{ admin.generateUrl('browse', {'type': 'audio'}) }}"
            });
        });
    </script>
{% endblock %}

Most likely, you don't need the entire configuration, but I provided a complete example for a better understanding.

The template can be overridden when registering the admin service:

services:
...
    harentius_blog.admin.article:
        class: Harentius\BlogBundle\Admin\ArticleAdmin
        tags:
            - { name: sonata.admin, manager_type: orm, group: Content, label: Articles, show_in_dashboard: true }
        arguments:
            - null
            - Harentius\BlogBundle\Entity\Article
            - HarentiusBlogBundle:CRUD
        calls:
            - [ setTranslationDomain, [ 'HarentiusBlogBundle' ] ]
            - [ setTemplate, [ 'edit', 'HarentiusBlogBundle:Admin/CRUD:edit_post.html.twig'] ]
            - [ setEntityManager, [ '@doctrine.orm.entity_manager' ] ]
....

Resources

  1. http://symfony.com/doc/current/controller/upload_file.html
  2. https://github.com/dustin10/VichUploaderBundle
  3. https://github.com/vitiko/IphpFileStoreBundle