Easy Multiple File Upload in Symfony using the CollectionType Field

File uploads have always been tricky, and handling them gracefully and painless is a challenging task. Avoid duplicates, handle removal and replacing without leaving orphan files somewhere in the database or on the server, showing a nice interface to the user, avoiding the ugly standard grey upload button … a lot of things need to be taken into account when working with file uploads. Luckily, Symfony has some great mechanisms which allow easing the task. In this tutorial I will be describing how I handle multiple file uploads using doctrine events and collection type form fields.

This scenario for this tutorial is a simple one : Folder and Document management. A Folder may contain a number of Documents, whereas a Document can only belong, and thus be uploaded, to a singe Folder. At any time during the Folder creation or modification, we need to be able to add or remove Documents from the Folder.

Aims of this tutorial :

  • Create a flexible multiple file upload, which takes advantage of Doctrine Events and CollectionType form fields
  • Handle file saving and deleting using only Doctrine Events, thus avoiding adding any extra task in the Controller

Resources :

Symfony version : 3.4 (LTS), but should work with all 3+ versions

Let’s get started !

In this tutorial we will only be handling Folder objects directly. All the document manipulation is done indirectly, using events and forms. The actions we need to implement are the following :

  • adding documents to a folder
  • removing documents from a folder
  • creating folders and adding documents to them
  • deleting folders and their associated documents

Part 1 : Entities

Our two main entities, Folder and Document, can be represented as follows :

A Document has a name and an extension, and it belongs to a single Folder (mandatory). Therefore, we have a @ManyToOne relationship between a Document and its corresponding Folder.

A Folder, on the other hand, only needs a name, and it will hold a collection of Documents. Therefore, we are inverting the previous @ManyToOne relationship by using a @OneToMany relationship between a Folder and all its corresponding Documents.

The Folder Entity relationship :

<?php

/**
 * Folder
 *
 * @ORM\Table(name="cookiejar_folder")
 * @ORM\Entity(repositoryClass="Playground\CookiejarBundle\Repository\FolderRepository")
 */
class Folder
{
    ...

    /**
     * Many Documents have one (the same) Folder
     * @ORM\OneToMany(targetEntity="Document", mappedBy="folder", cascade={"persist"}, orphanRemoval=true)
     */
	private $documents;

    ...

    /**
     * Add document
     *
     * @param \Playground\CookiejarBundle\Entity\Document $document
     *
     * @return Folder
     */
    public function addDocument(\Playground\CookiejarBundle\Entity\Document $document)
    {
        // Bidirectional Ownership
        $document->setFolder($this);
        
        $this->documents[] = $document;
        
        return $this;
    }
    
    /**
     * Remove document
     *
     * @param \Playground\CookiejarBundle\Entity\Document $document
     */
    public function removeDocument(\Playground\CookiejarBundle\Entity\Document $document)
    {
        $this->documents->removeElement($document);
    }

    /**
     * Get documents
     *
     * @return \Doctrine\Common\Collections\Collection
     */
    public function getDocuments()
    {
        return $this->documents;
    }

    /**
     * Constructor
     */
    public function __construct()
    {
        $this->documents = new \Doctrine\Common\Collections\ArrayCollection();
    }
}

Two things are particularly important here :

<?php    

/**
 * Many Documents have one (the same) Folder
 * @ORM\OneToMany(targetEntity="Document", mappedBy="folder", cascade={"persist"}, orphanRemoval=true)
 */
private $documents;

We only manipulate Folders. So we are only persisting Folder objects. Thus, we need to cascade persist our Documents.

Also, the orphanRemoval=true option assures that all Document objects which are removed from the array of Documents of a Folder, are also removed from the database. More details on the orphanRemoval option in Doctrine.

<?php 

/**
* Add document
*
* @param \Playground\CookiejarBundle\Entity\Document $document
*
* @return Folder
*/
public function addDocument(\Playground\CookiejarBundle\Entity\Document $document)
{
    // Bidirectional Ownership
    $document->setFolder($this);
    
    $this->documents[] = $document;
    
    return $this;
}

Again, since we only manipulating Folders, we need to make the Folder entity our owning side. Therefore, we must update the inverse side manually. More details on Owing and Inverse sides in Doctrine.

The Document Entity Class is a little more complicated. It contains a special setup that I use almost every time I need to handle file submissions. This method is described in this (complete) Symfony course.

Basically, we are handling all file manipulations using Doctrine Events. A file is stored in the database using three elements :

  1. its id, which is the name of the physical file
  2. its extension
  3. the file’s original name, as it was on the user’s device

This means that after uploading and persisting helloWorld.pdf, we will have an entry in the database with

(id=5, extension=’.pdf’, name=’helloWorld’)

and a physical file in our upload folder named “5.pdf”.

This solves the existing file name problem without using any fancy algorithms for random name generation.

Document Entity class contains 4 functions which are triggered by Doctrine events. The first two are triggered by the creation or the update of the object, while the last two are triggered by the removal of an object. Before persisting or updating the object, we save the name and the extension of the uploaded file into the object. After persisting or updating the object we remove old files if necessary and we copy the file to the proper location on the server. Removal is handled in a similar way. Before remove, we store a copy of the file name to delete, and after removal we delete the actual file of the disk.

There are numerous advantages to this method. So important that they require a dedicated article 😉 (again, I redirect all those who are interested in all the details and the benefits of this implementation to the original Symfony course).

<?php

namespace Playground\CookiejarBundle\Entity;

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

/**
 * Document
 *
 * @ORM\Table(name="cookiejar_document")
 * @ORM\Entity(repositoryClass="Playground\CookiejarBundle\Repository\DocumentRepository")
 * @ORM\HasLifecycleCallbacks
 */
class Document
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

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

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

	/**
     * Many Documents have one (the same) Folder
     * @ORM\ManyToOne(targetEntity="Folder", inversedBy="documents")
     * @ORM\JoinColumn(name="document_id", referencedColumnName="id")
     */
	private $folder;

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

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

        return $this;
    }

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

    /**
     * Set extension
     *
     * @param string $extension
     *
     * @return Document
     */
    public function setExtension($extension)
    {
        $this->extension = $extension;

        return $this;
    }

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

    /**
     * Set folder
     *
     * @param \Playground\CookiejarBundle\Entity\Folder $folder
     *
     * @return Document
     */
    public function setFolder(\Playground\CookiejarBundle\Entity\Folder $folder = null)
    {
        $this->folder = $folder;

        return $this;
    }

    /**
     * Get folder
     *
     * @return \Playground\CookiejarBundle\Entity\Folder
     */
    public function getFolder()
    {
        return $this->folder;
    }

	// https://openclassrooms.com/courses/developpez-votre-site-web-avec-le-framework-symfony/creer-des-formulaires-avec-symfony
	// Part 4.1. "Créer des formulaires avec Symfony"

	private $file;

	// Temporary store the file name
	private $tempFilename;

	public function getFile()
	{
		return $this->file;
	}

	public function setFile(UploadedFile $file = null)
	{
		$this->file = $file;

		// Replacing a file ? Check if we already have a file for this entity
		if (null !== $this->extension)
		{
			// Save file extension so we can remove it later
			$this->tempFilename = $this->extension;

			// Reset values
			$this->extension = null;
			$this->name = null;
		}
	}

	/**
	* @ORM\PrePersist()
	* @ORM\PreUpdate()
	*/
	public function preUpload()
	{
		// If no file is set, do nothing
		if (null === $this->file)
		{
			return;
		}

		// The file name is the entity's ID
		// We also need to store the file extension
		$this->extension = $this->file->guessExtension();

		// And we keep the original name
		$this->name = $this->file->getClientOriginalName();
	}

	/**
	* @ORM\PostPersist()
	* @ORM\PostUpdate()
	*/
	public function upload()
	{
		// If no file is set, do nothing
		if (null === $this->file)
		{
			return;
		}

		// A file is present, remove it
		if (null !== $this->tempFilename)
		{
			$oldFile = $this->getUploadRootDir().'/'.$this->id.'.'.$this->tempFilename;
			if (file_exists($oldFile))
			{
				unlink($oldFile);
			}
		}

		// Move the file to the upload folder
		$this->file->move(
			$this->getUploadRootDir(),
			$this->id.'.'.$this->extension
		);
	}

	/**
	* @ORM\PreRemove()
	*/
	public function preRemoveUpload()
	{
		// Save the name of the file we would want to remove
		$this->tempFilename = $this->getUploadRootDir().'/'.$this->id.'.'.$this->extension;
	}

	/**
	* @ORM\PostRemove()
	*/
	public function removeUpload()
	{
		// PostRemove => We no longer have the entity's ID => Use the name we saved
		if (file_exists($this->tempFilename))
		{
			// Remove file
			unlink($this->tempFilename);
		}
	}

	public function getUploadDir()
	{
		// Upload directory
		return 'uploads/documents/';
		// This means /web/uploads/documents/
	}

	protected function getUploadRootDir()
	{
		// On retourne le chemin relatif vers l'image pour notre code PHP
		// Image location (PHP)
		return __DIR__.'/../../../../web/'.$this->getUploadDir();
	}

	public function getUrl()
	{
		return $this->id.'.'.$this->extension;
	}
}

So at this point we have:

  • A Folder Entity class which is the owing side of a @OneToMany relationship with a Document Entity
  • A Document Entity which handles file uploads and removals using Doctrine Events

Part 2 : Forms

The Document Form is pretty straightforward since adding a document is basically having and making use of an input file button. So our form contains only a FileType field.

<?php
...
use Symfony\Component\Form\Extension\Core\Type\FileType;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Valid;
use Symfony\Component\Validator\Constraints\File;

class DocumentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
        ->add('file', FileType::class, array(
            'label' 	=> false,
            'required' 	=> true,
            'constraints' => array(
                new File(),
            ),
        ));
    
    }
    ...
}

The Folder form type, on the other hand, needs to handle adding multiple Documents to a Folder object. This is achieved by using a CollectionType Field. The official Symfony documentation states that the Collection Type Field “is used to render a ‘collection’ of some field or form”.

<?php
//...
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;

class FolderType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        
        $builder
            ->add('documents', CollectionType::class, array(
                'entry_type'   		=> DocumentType::class,
                'prototype'			=> true,
                'allow_add'			=> true,
                'allow_delete'		=> true,
                'by_reference' 		=> false,
                'required'			=> false,
                'label'			=> false,
        ));
        //...
    }
    //...
}

The field type is a CollectionType, since we need to manage a collection (an array) of documents for each folder object

  • The ‘entry_type’ is a DocumentType, since our collection contains Documents
  • The ‘prototype‘ option goes hand in hand with the ‘allow_add‘ option. When ‘prototype‘ is set to true, Symfony will generate a special “prototype” attribute that will be available in the form. This prototype is used to generate and render form parts which represent Document objects. When submitting the form, all these Document objects will be added to the Documents collection of the Folder object (thanks to the ‘allow_add‘ option which is set to true).
  • The ‘allow_delete‘ option is the equivalent of the ‘allow_add‘, but for object removal. All the items that are not contained in the submitted data, will be removed from the main object.
  • The ‘by_reference‘ option assures that the setter of the Documents field is called in all cases. We need this since the owning side of our bidirectional relationship is the Folder object. If the setter (addDocument in our case) is not called, the inverse side of the relationship is not updates and the newly created Documents are not added to the Folder object.
  • The rest of the options are pretty common :
    • required‘ is set to false, since we allow the creation of a folder without any documents
    • label‘ is set to false, since we want to style that one ourselves in the template

Now that our forms are set, let’s move on to the controllers.

Part 3 : Controller and Routing

This is going to be the easiest part. We only need a Folder Controller, since we are only manipulating Folder objects. And three routes: one for adding Folders, one for editing them and a last one for viewing them.

cookiejar_folder_add:
    path:     /cookieja/folder/add
    defaults:
        _controller: PlaygroundCookiejarBundle:Folder:add

cookiejar_folder_edit:
    path:     /cookieja/folder/{id}/edit
    defaults:
        _controller: PlaygroundCookiejarBundle:Folder:edit
        requirements:
            id: \d+

cookiejar_folder_view:
    path:     /cookieja/folder/{id}/view
    defaults:
        _controller: PlaygroundCookiejarBundle:Folder:view
        requirements:
            id: \d+
<?php
/*
--------------------------------------------------------------------------------
src/Playground/CookiejarBundle/Controller/FolderController.php
--------------------------------------------------------------------------------
*/
namespace Playground\CookiejarBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

use Playground\CookiejarBundle\Entity;
use Playground\CookiejarBundle\Entity\Folder;
use Playground\CookiejarBundle\Form\FolderType;

class FolderController extends Controller
{
	protected $entityManager;
	protected $translator;
	protected $repository;

	// Set up all necessary variable
	protected function initialise()
	{
		$this->entityManager = $this->getDoctrine()->getManager();
		$this->repository = $this->entityManager->getRepository('PlaygroundCookiejarBundle:Folder');
		$this->translator = $this->get('translator');
	}

	public function addAction(Request $request)
	{
		// Set up required variables
		$this->initialise();

		// New object
		$folder = new Folder();

		// Build the form
		$form = $this->get('form.factory')->create(FolderType::class, $folder);

		if ($request->isMethod('POST'))
		{
			$form->handleRequest($request);
			// Check form data is valid
			if ($form->isValid())
			{
				// Save data to database
				$this->entityManager->persist($folder);
				$this->entityManager->flush();

				// Inform user
				$flashBag = $this->translator->trans('folder_add_success', array(), 'flash');
				$request->getSession()->getFlashBag()->add('notice', $flashBag);

				// Redirect to view page
				return $this->redirectToRoute('cookiejar_folder_view', array(
					'id'	=>	$folder->getId(),
				));
			}
		}
		// If we are here it means that either
		//	- request is GET (user has just landed on the page and has not filled the form)
		//	- request is POST (form has invalid data)
		return $this->render(
			'@CookiejarBundle/Folder/add.html.twig',
			array (
				'form'	=>	$form->createView(),
			)
		);
	}

	public function editAction(Request $request, Folder $folder)
	{
		// Set up required variables
		$this->initialise();

		// Build the form
		$form = $this->get('form.factory')->create(FolderType::class, $folder);

		if ($request->isMethod('POST'))
		{
			$form->handleRequest($request);
			// Check form data is valid
			if ($form->isValid())
			{
				// Save data to database
				$this->entityManager->persist($folder);
				$this->entityManager->flush();

				// Inform user
				$flashBag = $this->translator->trans('folder_edit_success', array(), 'flash');
				$request->getSession()->getFlashBag()->add('notice', $flashBag);

				// Redirect to view page
				return $this->redirectToRoute('cookiejar_folder_view', array(
					'id'	=>	$folder->getId(),
				));
			}
		}
		// If we are here it means that either
		//	- request is GET (user has just landed on the page and has not filled the form)
		//	- request is POST (form has invalid data)
		return $this->render(
			'@CookiejarBundle/Folder/edit.html.twig',
			array (
				'form'		=>	$form->createView(),
				'folder'	=>	$folder
			)
		);
	}
}<span id="mce_marker" data-mce-type="bookmark" data-mce-fragment="1">​</span>

Nothing special going on here.

The addAction function (line 31) creates a new Folder object and assigns to a new FolderType form object (line 40). If the submitted form is valid, the data is committed to the database (lines 49, 50). The editAction function (line 73) does exactly the same thing, with the exception that the Folder object to be modified is fetched from the database.

Part 4 : Templates

Part 4.a. Creating a Folder

{{ form_start(form) }}
{{ form_errors(form) }}

{# The Folder.name field #}
<div class="row form-group">
    {{ form_label(form.name, null, {'label_attr': {'class': 'col-md-2'}}) }}
    <div class="col col-md-10">{{ form_widget(form.name) }}</div>
    <div class="form_errors col-md-12">
	    {{ form_errors(form.name) }}
    </div>
</div>

{# The Prototype to use for generating items of the Folder.documents collection #}
<div id="filesProto" data-prototype="{{ form_widget(form.documents.vars.prototype)|e }}"></div>

{# An empty (fro now) div which will later hold the generated document fields #}
<div class="row">
    <div class="col col-xs-12" id="filesBox">
    </div>
</div>

{# Submit part #}
<div class="row form-group">
    <div class="form_cancel col-md-offset-8 col-md-2 col-xs-4">
	    <a href="{{ path('cookiejar_homepage') }}" class="btn btn-default btn-block">{{ 'standard_cancel' | trans }}</a>
    </div>
    <div class="form_submit col-md-2 col-xs-8">
	    <input type="submit" value="{{ 'standard_validate' | trans }}" class="btn btn-warning btn-block" />
    </div>
</div>

{{ form_end(form) }}

The interesting part in this template is the following line :

<div id="filesProto" data-prototype="{{ form_widget(form.documents.vars.prototype)|e }}"></div>

If we inspect the source of the page and look for that particular div, we can see some like this :

The div contains a prototype of a Document field. In order to generate the actual Document fields, all we have to do is fetch the prototype, replace the __name__ part with an unique value, and dynamically insert it into the page. Here is the code that does that, using JQuery :

// Keep count of how many elements have been added
var fileCount = '{{ form.documents|length }}';

function createAddFile(fileCount)
{
	// grab the prototype template
	var newWidget = $("#filesProto").attr('data-prototype');
	// replace the "__name__" used in the id and name of the prototype
	newWidget = newWidget.replace(/__name__/g, fileCount);

	$("#filesBox").append("<div class='row'>" + "<div class='col-md-10'>" + newWidget + "</div></div>");

	// Once the file is added
	$('#playground_cookiejarbundle_folder_documents_' + fileCount + '_file').on('change', function() {
		// Create another instance of add file button and company
		createAddFile(parseInt(fileCount)+1);
	});
}

$(document).ready(function(){
	createAddFile(fileCount);
	fileCount++;
});

The createAddFile function first grabs the prototype of the Document field and replaces the __name__ part with the value of the fileCount variable. This variable actually counts how many Documents have been rendered. When creating a new Folder, its initial value in zero. But then we edit a Folder which already contains some Documents, the value of the fileCount variable needs to correspond to the number of those Documents. Hence its initialisation : var fileCount = '{{ form.documents|length }}');

Next, the Document field is inserted into the page (line 11).

And finally, we recursively call the same function in order to generate another Document upload field.

When the page has loaded, we call the createAddFile function once, to generate the first Document upload field.

So now we have a page which allows uploading as many Documents as we want when creating a Folder. Click here to see an animation of the result.

Next, we need to add a remove button for each individual file. In order to do that, we create a removeFile function which simply removes the Document field from the form (as we saw in the Form section, the ‘allow_add‘ option will only add those fields which are present in the form at submission). Here is the modified code (the modifications are highlighted):

var fileCount = '{{ form.documents|length }}';
var removeButton = "<button type='button' class='btn btn-danger btn-xs' onclick='removeFile($(this));'><i class='fa fa-times' aria-hidden='true'></i></button>";

function removeFile(ob)
{
	ob.parent().parent().remove();
}

function createAddFile(fileCount)
{
	// grab the prototype template
	var newWidget = $("#filesProto").attr('data-prototype');
	// replace the "__name__" used in the id and name of the prototype
	newWidget = newWidget.replace(/__name__/g, fileCount);

	$("#filesBox").append("<div class='row'>" + "<div class='col-md-1'>" + removeButton + "</div><div class='col-md-10'>" + newWidget + "</div></div>");

	// Once the file is added
	$('#playground_cookiejarbundle_folder_documents_' + fileCount + '_file').on('change', function() {
		// Create another instance of add file button and company
		createAddFile(parseInt(fileCount)+1);
	});
}

$(document).ready(function(){
	createAddFile(fileCount);
	fileCount++;
});

Click here to see an animation of the result.

Before moving on to editing a Folder and its Documents, let’s style all this a little.

What we are doing is hiding the upload button for each Document, and replacing it with a fancy button which will trigger the click event of the original upload button.

// On click => Simulate file behaviour
$("#jsBtnUpload" + fileCount).on('click', function(e){
	$('#playground_cookiejarbundle_folder_documents_' + fileCount + '_file').trigger('click');
});

And here is the complete code:

var fileCount = '{{ form.documents|length }}';
var removeButton = "<button type='button' class='btn btn-danger btn-xs' onclick='removeFile($(this));'><i class='fa fa-times' aria-hidden='true'></i></button>";

function removeFile(ob)
{
	ob.parent().parent().remove();
}

function createAddFile(fileCount)
{
	// grab the prototype template
	var newWidget = $("#filesProto").attr('data-prototype');
	// replace the "__name__" used in the id and name of the prototype
	newWidget = newWidget.replace(/__name__/g, fileCount);

	newWidget = "<div style='display:none'>" + newWidget + "</div>";

	hideStuff = "";
	hideStuff += "<div class='col col-xs-1' id='jsRemove" + fileCount + "' style='display: none;'>";
		hideStuff += removeButton;
	hideStuff += "</div>";

	hideStuff += "<div class='col col-xs-11' id='jsPreview" + fileCount + "'>";
	hideStuff += "</div>";

	hideStuff += "<div class='col col-xs-12'>";
		hideStuff += "<button type='button' id='jsBtnUpload" + fileCount + "' class='btn btn-warning'>";
			hideStuff += "<i class='fa fa-plus'></i> {{ 'document' | trans }}";
		hideStuff += "</button>";
	hideStuff += "</div>";

	$("#filesBox").append("<div class='row'>" + hideStuff + newWidget + "</div>");

	// On click => Simulate file behaviour
	$("#jsBtnUpload" + fileCount).on('click', function(e){
		$('#playground_cookiejarbundle_folder_documents_' + fileCount + '_file').trigger('click');
	});

	// Once the file is added
	$('#playground_cookiejarbundle_folder_documents_' + fileCount + '_file').on('change', function() {
		// Show its name
		fileName = $(this).prop('files')[0].name;
		$("#jsPreview" + fileCount).append(fileName);
		// Hide the add file button
		$("#jsBtnUpload" + fileCount).hide();
		// Show the remove file button
		$("#jsRemove" + fileCount).show();

		// Create another instance of add file button and company
		createAddFile(parseInt(fileCount)+1);
	});
}

$(document).ready(function(){
	createAddFile(fileCount);
	fileCount++;
});

Part 4.b. Editing a Folder and its list of Documents

Now that we have successfully created a folder and its corresponding documents, the next step is to modify it. For that, we are going to build upon the same form template. The only thing that changes is the list of the Documents that have already been added to the Folder.  Since our FolderType form handles the Documents fields using a CollectionType, the existing Documents are contained in the form data. So all we need to do is add them to the page.

Remember this empty div :

<div class="row">
    <div class="col col-xs-12" id="filesBox">
    </div>
</div>

When generating the page, we simply fill it with the Documents already contained in the Collection:

<div class="row">
    <div class="col col-xs-12" id="filesBox">
        {% set pos = 0 %}
            {% for doc in form.documents %}
            
            <div class="row">
                <div class="col col-xs-1" id="jsRemove{{ pos }}" style="">
                   <button type="button" class="btn btn-danger" onclick="removeFile($(this));"><i class="fa fa-times" aria-hidden="true"></i></button>
                </div>
                <div class="col col-xs-11" id="jsPreview{{ pos }}">{{ doc.vars.value.name }}</div>
                
                <div style="display:none">
                  {{ form_widget(doc) }}
                </div>
            </div>
            
            {% set pos = pos + 1 %}
        {% endfor %}
    </div>
</div>

We could have simply written

<div class="row">
	<div class="col col-xs-12" id="filesBox">
		{% set pos = 0 %}
		  {{ form_widget(doc) }}
		{% endfor %}
	</div>
</div>

But we want the old Documents’ lines to resemble to the new ones we will be adding. So we are reproducing here the code which is usually generated by the createAddFile function.

When loading the edit page for a Folder containing Documents, the filesBox div is filled with the form fields of those documents. The fileCount variable is initialised with the total number of Documents available. When we add a new Document, it is added to the collection thanks to the ‘allow_add‘ option in the form. When we remove a Document (existing or new) we are actually removing its field from the form. Since the ‘allow_remove‘ option is set to true, this will remove the actual object from the collection. The orphanRemoval parameter in the Folder Entity class makes sure that the Documents which have been removed from the collection of a Folder are also removed from the database.

So here it is, a fully functional, flexible, multiple file upload which takes a maximum advantage of Events and CollectionTypes.

Part 5 : The Final Code

… is on GitHub

Part 6 : Further improvements

… are, of course, possible.

  • [Security] File type and size checks should be done on both the client and the server side
  • [Comfort] When uploading images, a preview should be displayed to the user
  • [Functionality] Deleting a Folder and its Documents

19 thoughts to “Easy Multiple File Upload in Symfony using the CollectionType Field”

  1. Thank you for this helpful code. I want to know how can I add a description for each file during the multiple file’s uploads ?

    1. Hello Jean,
      Simply add a ‘description’ field to your DocumentType entity and include it in the DocumentFormType also. The prototype will thus contain both the name of the document and its description (the newWidget in the JS). Then you might need some JavaScript tweaking to render this filed visible while keeping the upload button hidden. But once you manage to make your input field visible, simply editing it will save the description along with the document data.

  2. Thank you for this important article, its very helpful for me.

    I did all of what you describe in this article and adapt it for my project, but in final i have a button {{ ‘document’ | trans }} that do nothing. I don’t know where is the problem, but when i test just the basic form without delete button, i have an error with the var fileCount = ‘{{ form.documents|length }}’ the problem that the caractere “|” is not accepted.

    1. The | character cannot be the problem, since it is a valid twig instruction. Are you using it in a .html.twig file as described in the article ?

      1. Yes. My need is to have multiple images for one product, i replace Folder by Product, and Document by Image, and i save the js script in a file and add it as script in my template base.html.twig, and my template new.html.twig that add new product inherite from base.html.twig.

    1. Hello Dima,
      Have you also implemented the file upload mechanisms in the Entity handling the files ?
      In my example, I handle the file uploads with ORM Event (see the Document class entity above). When persist a Folder object, all related documents are handled also. If you don’t map the file objects however, you should do all these operations (renaming the file, saving it in the right location etc…) yourself in the controller.

      1. I can handle all file savings etc, BUT a problem is that if I use single form file like :add(‘fileNames’, FileType::class..) it is all fine, when I use collection, the request gets empty file objects. As written in question $_FILES array is ok. So actually saving is not a problem, a problem to get file object from the request

  3. Please can you help me with this: Expected argument of type “AppBundle\Entity\Document”, “array” given

Leave a Reply to Livia Cancel reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.