Загрузка файлов в Symfony

Загрузка файлов в Symfony идейно ничем не отличается от других php-платформ, но все же имеет свои особенности вследствие наличия дополнительных инструментов, предоставляемых фреймворком.

Вначале стоит отметить, что существуют готовые решения, решающие поставленную задачу. Настоятельно рекомендую ознакомится с ними, и только после этого, если решите, что они вам не подходят, делать свою реализацию.

В этой заметке попытаемся показать возможные пути решения поставленной задачи, как с помощью готовых решений (VichUploaderBundle, IphpFileStoreBundle), так и с помощью собственной реализации (в контроллерах Symfony и админ классах SonataAdminBundle).

1. VichUploaderBundle

Использование предельно простое.

Установка:

composer require vich/uploader-bundle
 

Добавляем в AppKernel:

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

Базовая конфигурация:

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

Дальше, нужно сконфигурировать типы загружаемых файлов. Например, сконфигурируем загрузку аватаров пользователей:

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

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

После этого, добавляем необходимые поля в ентити:

<?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;
    }
}

Дальше создаем форму и используем один из форм тайпов https://github.com/dustin10/VichUploaderBundle/tree/master/Form/Type

Например:

<?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

Форк предыдущего бандла. Из ключевых отличий - улучшена интеграция с SonataAdminBundle, а также расширенная сохраняемая информация о файле (в поле хранится массив метаинформации, а не путь до файла).

Использование очень похоже на VichUploaderBundle, более подробно в документации.

3. Symfony 

Если по какой-то причине вам не подошли предыдущие бандлы, можно реализовать загрузку самому.

Покажем на примере того же User'а:

1)  Ентити:

<?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;
    }
}

2) Форма:

<?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,
        ));
    }
}

Дальше рендерим форму. Самое интересное - обработка данных. Для упрощения, покажем, как это сделать в контроллере (пример частично позаимствован с документации Symfony). В идеале, логика обработки загрузки файла должна быть вынесена в отдельный сервис. Так же можно вынести логику в ивенты Доктрины.

<?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

Напоследок хотелось бы показать пример загрузки файла в SonataAdminBundle.

Простейший вариант - перенести логику с предыдущего примера в prePersist/preUpdate хуки - напрашивается сам собой. (Если вы все же решили сделать этот вариант и не поленились выполнить последнюю рекомендацию и перенести логику обработки загрузки в отдельный сервис - это очень облегчит задачу). (На самом деле, я немного лукавлю, можно еще проще: если вынести логику загрузки в ивенты Доктрины, то никакой дополнительной обработки в хуках Sonata вообще не нужно добавлять.)

Покажем немного более "продвинутый" вариант загрузки изображений с помощью ckeditor и библиотекой изображений. (Например, для вставки в заметки,  а не просто как поле ентити).

Для этих целей создадим Контроллер для 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,
        ]);
    }
}

Первый метод использует сервис для загрузки файлов (посмотреть на реализацию), а второй  формирует список файлов для отображения в ckeditor.

Темплейты, используемые этим контроллером:

{# 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>

Инициализация 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 %}

Скорее всего, вам не нужна вся конфигурация, но для видения полной картины привел полный пример.

Темплейт можно перебить при регистрации админ сервиса:

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' ] ]
....

Ресурсы:

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