If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWhen I started creating this tutorial, I got a lot of requests for things to talk about... which, by the way - thank you! That was awesome! Your requests absolutely helped drive this tutorial. One request that I heard over and over again was: handling multiple file uploads at a time.
It makes sense: instead of uploading files one-by-one, an author should be able to select a bunch at a time! This is something that's totally supported by the web: if you add a multiple
attribute to a file input, boom! Your browser will allow you to select multiple files. In Symfony, we would then be handling an array of UploadedFile
objects, instead of one.
But, I'm not going to show how to do that. Mostly... because I don't like the user experience! What if I select 10 files, wait for all of them to upload, then one is too big and fails validation? If you're not inside a form, you could probably save 9 of them and send back an error. But if you're inside a form, good luck: unless you do some serious work, none of them will be saved because the entire form was invalid!
I also want my files to start uploading as soon as I select them and I want a progress bar. Basically... I want to handle uploads via JavaScript. In fact, over the next few videos, we're going to create a pretty awesome little widget for uploading multiple files, deleting them, editing their filenames and even re-ordering them.
First: the upload part. Google for a library called Dropzone: it's probably the most popular JavaScript library for handling file uploads. It creates a little... "drop zone"... and when you drop a file here or select a file, it starts uploading. Super nice!
Search for a Dropzone CDN. I normally use Webpack Encore, and so, whenever I need a third-party library, I install it via yarn and import it when I need to use it. If you're using Encore, you can do this - and I recommend it. But in this tutorial, to keep things simple, we're not using Encore. And so, in our edit template, we're including a normal JavaScript file that lives in the public/js/
directory: admin_article_form.js
, which holds some pretty traditional JavaScript.
To get Dropzone rocking, copy the minified JavaScript file and go to the template Actually, copy the whole script tag with SRI - that'll include the nice integrity
attribute.
... lines 1 - 47 | |
{% block javascripts %} | |
... lines 49 - 50 | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.5.1/min/dropzone.min.js" integrity="sha256-cs4thShDfjkqFGk5s2Lxj35sgSRr4MRcyccmi0WKqCM=" crossorigin="anonymous"></script> | |
... line 52 | |
{% endblock %} |
Grab the minified link tag too. We don't have a stylesheets
block yet, so we need to add one: {% block stylesheets %}{% endblock %}
, call {{ parent() }}
and paste the link tag.
... lines 1 - 41 | |
{% block stylesheets %} | |
{{ parent() }} | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.5.1/min/dropzone.min.css" integrity="sha256-e47xOkXs1JXFbjjpoRr1/LhVcqSzRmGmPqsrUQeVs+g=" crossorigin="anonymous" /> | |
{% endblock %} | |
... lines 47 - 54 |
Dropzone basically "takes over" your form tag. You don't need a button anymore... or even the file input. The form tag does need a dropzone
class... but that's it!
... lines 1 - 2 | |
{% block content_body %} | |
... lines 4 - 7 | |
<div class="row"> | |
... lines 9 - 14 | |
<div class="col-sm-4"> | |
... lines 16 - 33 | |
<form action="{{ path('admin_article_add_reference', { | |
id: article.id | |
}) }}" method="POST" enctype="multipart/form-data" class="dropzone"> | |
</form> | |
</div> | |
</div> | |
{% endblock %} | |
... lines 41 - 54 |
Try it! Refresh and... hello Dropzone!
When you select a file with Dropzone, it's smart enough to upload to the action
URL on our form. So... in theory... it should just... sort of work.
Back in the controller, scroll up to the upload endpoint and dump($uploadedFile)
. I'm not using dd()
- dump and die - because this will submit via AJAX - and by using dump()
without die'ing, we'll be able to see it in the profiler.
... lines 1 - 19 | |
class ArticleReferenceAdminController extends BaseController | |
{ | |
... lines 22 - 25 | |
public function uploadArticleReference(Article $article, Request $request, UploaderHelper $uploaderHelper, EntityManagerInterface $entityManager, ValidatorInterface $validator) | |
{ | |
/** @var UploadedFile $uploadedFile */ | |
$uploadedFile = $request->files->get('reference'); | |
dump($uploadedFile); | |
... lines 31 - 76 | |
} | |
... lines 78 - 101 | |
} |
Ok: select a file. The first cool thing is that the file upload AJAX request showed up down on the web debug toolbar! I'll click the hash and open that up in a new tab.
This is awesome! We're now looking at all the profiler data for that AJAX request! Actually... hmm... that's not true. Look closely: it says that we were redirected from a POST request to the admin_article_add_reference
route. We're looking at the profiler for the article edit page!
This is a bit confusing. Click the "Last 10" link to see a list of the last 10 requests made into our app. Now it's more obvious: Dropzone made a POST request to /admin/article/41/references
- that's our upload endpoint. But, for some reason, that redirected us to the edit page. Click the token link to see the profiler for the POST request.
Check out the Debug tab. There it is: this is the dump from our controller... and it's null. Where's our upload? The problem is that, by default, Dropzone uploads a field called file
. But in the controller, we're expecting it to be called reference
.
We could fix this in the controller... but we can also configure Dropzone to use the reference
key. We're going to do that because, in general, as cool as it is that we can just add a "dropzone" class to our form and it mostly works, to really get this system working, we're going to need to customize a bunch of things on Dropzone.
Open up admin_article_form.js
. First, at the very top, add Dropzone.autoDiscover = false
. That tells Dropzone to not automatically configure itself on any form that has the dropzone
class: we're going to do it manually.
Dropzone.autoDiscover = false; | |
... lines 3 - 42 |
Try it out - close the extra tab and refresh. Hmm... still there? Maybe a force refresh? Now it's gone. The dropzone
class still gives us some styling, but it's not functional anymore.
To get it working again, inside the document.ready()
, call a new initializeDropzone()
function.
... lines 1 - 2 | |
$(document).ready(function() { | |
initializeDropzone(); | |
... lines 5 - 29 | |
}); | |
... lines 31 - 42 |
Copy that name, and, below, add it: function initializeDropzone()
. If I were using Webpack Encore, I'd probably organize this function into its own file and import it.
... lines 1 - 31 | |
function initializeDropzone() { | |
... lines 33 - 40 | |
} |
The goal here is to find the form
element and initialize Dropzone
on it. To do that, let's add another class on the form: js-reference-dropzone
.
... lines 1 - 2 | |
{% block content_body %} | |
... lines 4 - 7 | |
<div class="row"> | |
... lines 9 - 14 | |
<div class="col-sm-4"> | |
... lines 16 - 33 | |
<form action="{{ path('admin_article_add_reference', { | |
id: article.id | |
}) }}" method="POST" enctype="multipart/form-data" class="dropzone js-reference-dropzone"> | |
</form> | |
</div> | |
</div> | |
{% endblock %} | |
... lines 41 - 54 |
Copy that, and back inside our JavaScript, say var formElement = document.querySelector()
with .js-reference-dropzone
.
... lines 1 - 31 | |
function initializeDropzone() { | |
var formElement = document.querySelector('.js-reference-dropzone'); | |
... lines 34 - 40 | |
} |
Yes, yes, I'm using straight JavaScript here instead of jQuery to be a bit more hipster - no big reason for that. There's also a jQuery plugin for Dropzone. Next, to avoid an error on the "new" form that doesn't have this element, if !formElement
, return
.
... lines 1 - 31 | |
function initializeDropzone() { | |
var formElement = document.querySelector('.js-reference-dropzone'); | |
if (!formElement) { | |
return; | |
} | |
... lines 37 - 40 | |
} |
Finally, initialize things with var dropzone = new Dropzone(formElement)
. And now we can pass an array of options. The one we need now is paramName
. Set it to reference
.
... lines 1 - 31 | |
function initializeDropzone() { | |
var formElement = document.querySelector('.js-reference-dropzone'); | |
if (!formElement) { | |
return; | |
} | |
var dropzone = new Dropzone(formElement, { | |
paramName: 'reference' | |
}); | |
} |
That should do it! Head over and select another file - how about earth.jpeg
. And... cool! It looks like it worked. Click to open the profiler for the AJAX request.
Oh... careful - once again, we got redirected! So this is the profiler for the edit page. Click the link to go back to the profiler for the POST request and go back to the Debug tab. Yes! Now we're getting the normal UploadedFile
object.
Close this and refresh. Look at the list! There is earth.jpeg
! It worked! Of course, it's a little weird that it redirected after success... and if there were a validation error... that would also cause a redirect... and so it would look successful to Dropzone. The problem is that our endpoint isn't set up to be an API endpoint. Let's fix that next and make Dropzone read our validation errors.
Hey Ernest R.
I guess everything is looking good here but what about JS? Have you stopped original browser event? You button just submits form that's why you are redirected. You should stop the original submit event with event.preventDefault()
depending on how you are doing ajax request!
Cheers!
Hi Vladimir,
I finnaly discard using ajax for create the register, was not worth it for just one field i have to enter.
I tried using the event.preventDefault(), and could stop the submit, but i wasn't able to used inside the class as is used in the example, so i used in the document.ready, but after that i couldn't execut the ajax call, no error, no warning, i guess i wasn't pointing correctly to the mark.
My js script ( added a id to the form)
`
$(document).ready(function() {
document.getElementById("js-add-page").addEventListener("click", function(event){
event.preventDefault()
});
var pageList = new PageList($('.js-page-list'), $('.js-add-page'));
});
class PageList
{
constructor($element, $form) {
this.$element = $element;
this.$form = $form
this.pages = [];
this.render();
this.$form.on('click', '.js-add-page', (event) => {
console.log('test')
});
this.$element.on('click', '.js-page-delete', (event) => {
var getDelMessage = document.querySelector('.js-del-message');
var delMessage = getDelMessage.dataset.delMessage;
confirm(delMessage);
this.handlePageDelete(event);
});
$.ajax({
url: this.$element.data('url')
}).then(data => {
this.pages = data;
this.render();
})
}
handlePageDelete(event) {
const $li = $(event.currentTarget).closest('.list-group-item');
const id = $li.data('id');
$li.addClass('disabled');
$.ajax({
url: '/admin/delPage/'+id,
method: 'DELETE'
}).then(() => {
this.pages = this.pages.filter(page => {
return page.id !== id;
});
this.render();
});
}
render() {
const itemsHtml = this.pages.map(page => {
return `
<li class="list-group-item d-flex justify-content-between align-items-center" data-id="${page.id}">
<span class="drag-handle fa fa-reorder"></span>
<input type="text" value="${page.slug}" class="form-control js-edit-filename" style="width: auto;">
<a href="/admin/editPage/${page.id}">
<span class="glyphicon glyphicon-pencil"></span>
</a>
<a class="js-page-delete" ><span
class="glyphicon glyphicon-trash text-red"></span></a>
</li>
`
});
this.$element.html(itemsHtml.join(''));
}
}
`
Thanks Vladimir.
hm... $('.js-add-page')
is a form element, so you should listen to submit
event instead of click
something like this
this.$form.on('submit', (event) => {
event.preventDefault();
console.log('test')
});
cheers!
Sorry, but what about the real multiple files uploading?The Dropzone.js uploads the files one by one. Can you please advice how to count then the uploaded files (at once) on the server side? Thank you
Hey Anton,
This comment might be useful for you, please, take a look at it: https://symfonycasts.com/sc... . But really, having nice JS library that helps to handle multiple uploads is enough. Fairly speaking, upload files one by one technically has less possible problems, and if you have a nice JS library with nice UI and UX - that's perfect. You should not care about how it works behind the scene - all the job is done by JS library. What you do care is UX and that handling single file uploads on the server side is just easier. So, as for me, the ideal scheme for uploading multiple files - have a JS library with nice UX that allows users to select multiple files at once and that uploads those files one by one to the server side where your PHP script handles those uploads.
I hope this helps!
Cheers!
Hi! How do you solve the new item issue? What I mean is, I create a new Article, in the form I fill all the fields and upload some files.. we don't know the new article_id, how do you store them? How do you do the relationship? Thanks
Hey Roberto S. !
Great question :). I cheat.. and I feel great about it :). If I have an upload on some sort of an entity, I *always* will save the original object (e.g. Article) first. It just makes life MUCH simpler. There are bundles out there that have really cool fanciness to save files in a temporary location that you can then read when your form actually submits... but I've never thought it worth the trouble. For an Article, it would look like this:
Add a step 1 where the user at least needs to set a "title" so you have *something* to add to the database. You'll then need to put any "NotBlank" annotations you want in some validation group that will only be activated on the 2nd step. Btw, for that "step 1". I've even seen it where it looks like 1 form, but first you only see the title. Once you type that in, it saves via AJAX and the rest of the form loads.
In the end, the problem you're referring to simply is a tricky one. That's why I like to cheat. There are bundles that can help (I think https://github.com/1up-lab/... has this), but it ultimately comes down to some tricks. For example, you could also allow your related objects to save with a null article_id... store THEIR ids in the session (or, more fancy, return their ids back, and put them in the form as hidden fields) and set them when the form FINALLY saves (and have a process to clean up old records for forms that NEVER got submitted successfully).
Let me know if this helps :).
Cheers!
Hey Skylar!
Hmm, maybe? :) There are two parts to that:
1) First, you'll need to base64 encode the uploaded file data so that it can be sent. That *does* appear to be possible: https://stackoverflow.com/q...
2) Then you need to teach Dropzone to send JSON, instead of the normal format. I'm not sure that's possible. Someone forked the entire library to add it - https://github.com/slothbag... but they changed a lot of core code to make it possible. Someone else "sorta" did it - https://github.com/rowanwin... - but that's a bit of a hack: they are *still* sending a traditional form submit, but with JSON as one of the keys (it's not *really* a JSON formatted request).
So... it looks like... kind of :). If you want an "pure" JSON upload endpoint for some other use (beyond Dropzone), the easier solution might be to have your endpoint support both JSON input and normal, form-submitted input (from Dropzone).
Cheers!
Hey sasa1007!
Haha, we could! But this form would just have one field... and that field would be an instance of an UploadedFile object. So, I'm not sure the form would give us much in this case. But, what do you think?
Cheers!
Hi!!!!
I'm glad that You understand my comment in the right way :-), I enjoy watching this tutorial.
So my idea is to create one entity - Medias, and only in Medias to upload files. Medias will be related to every entity that we need picture (Blog entity, User entity....). From user perspective, user can upload more picture at once in Medias (somehow), and then he can use those pictures (one or more) for blogs or blog or other entity where picture is needed. And at the end I want to copy that entity, and logic for upload from project to project and reuse that, and of course I want to use symfony components(forms). And now when I use symfony forms dropzone trow error that he can find url.
Hey sasa1007!
Makes sense! Honestly, what you probably want is a setup like what we have here - where you use Dropzone outside of a a form to create the Medias and relate it to whatever object you need (you would need to send up some additional flag in the URL to say which object the Media should be related to). But, there are so many variants on how you can want this to look and work.
So, are you creating a traditional Symfony form then adding a Dropzone inside of it? By default, Dropzone will use the action=""
of your form tag as the upload URL. You can by passing a url
option when you initialize Dropzone. But then, Dropzone isn't really using your form... it's still uploading independently of your form.
That's a long way of saying: what exactly are you trying to accomplish by putting it into a form? What is the user flow you imagine?
Cheers!
// composer.json
{
"require": {
"php": "^7.1.3",
"ext-iconv": "*",
"aws/aws-sdk-php": "^3.87", // 3.87.10
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
"knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
"knplabs/knp-time-bundle": "^1.8", // 1.9.0
"league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
"league/flysystem-cached-adapter": "^1.0", // 1.0.9
"liip/imagine-bundle": "^2.1", // 2.1.0
"nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
"oneup/flysystem-bundle": "^3.0", // 3.0.3
"php-http/guzzle6-adapter": "^1.1", // v1.1.1
"sensio/framework-extra-bundle": "^5.1", // v5.2.4
"stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
"symfony/asset": "^4.0", // v4.2.3
"symfony/console": "^4.0", // v4.2.3
"symfony/flex": "^1.9", // v1.17.6
"symfony/form": "^4.0", // v4.2.3
"symfony/framework-bundle": "^4.0", // v4.2.3
"symfony/orm-pack": "^1.0", // v1.0.6
"symfony/security-bundle": "^4.0", // v4.2.3
"symfony/serializer-pack": "^1.0", // v1.0.2
"symfony/twig-bundle": "^4.0", // v4.2.3
"symfony/validator": "^4.0", // v4.2.3
"symfony/web-server-bundle": "^4.0", // v4.2.3
"symfony/yaml": "^4.0", // v4.2.3
"twig/extensions": "^1.5" // v1.5.4
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
"easycorp/easy-log-handler": "^1.0.2", // v1.0.7
"fzaninotto/faker": "^1.7", // v1.8.0
"symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
"symfony/dotenv": "^4.0", // v4.2.3
"symfony/maker-bundle": "^1.0", // v1.11.3
"symfony/monolog-bundle": "^3.0", // v3.3.1
"symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
"symfony/profiler-pack": "^1.0", // v1.0.4
"symfony/var-dumper": "^3.3|^4.0" // v4.2.3
}
}
Hello Ryan and thanks for the tutorial, i have followed it and with some minor tweaks i created A gellery of images that can be reordered!
I'm trying to recreate some parts for another part of my app but I'm having the problem when i start an ajax request it redirects me and shows me the json in the screen.
My controller
/**
* @Route("/admin/addPage", name="add_page", methods={"POST"} )
*/
public function addPage(Request $request)
{
$page = new Page;
$slug = $request->get('page_slug');
$page->setSlug($slug);
$this->em->persist($page);
$this->em->flush();
return $this->json(
$page,
201,
[],
[
'groups' => ['main']
]
);
}
my form
<form action="{{ path('add_page') }}" method="POST" class="js-add-page">
<div class="form-group">
<label>{{ 'page_manager.slug'|trans }}</label>
<input type="text" class="form-control" name="page_slug">
</div>
<button class="btn btn-primary ">{{ 'page_manager.create_page'|trans }}</button>
</form>
Thanks!