ethsam/symfony-dropzone
Symfony Dropzone Bundle integrates Dropzone.js drag-and-drop uploads into Symfony Forms, with automatic Doctrine entity relationship handling, pre-populated edit forms, image resizing, and configurable single or multiple file modes.
Seamless integration of Dropzone.js into Symfony Forms with automatic entity relationship management for drag-and-drop file uploads.
EntityType the single-file mode buildscomposer require ethsam/symfony-dropzone
The bundle registers automatically with Symfony Flex. If you're not using Flex, add to config/bundles.php:
Ethsam\SymfonyDropzone\SymfonyDropzoneBundle::class => ['all' => true],
Add the following to your base template (e.g., base.html.twig):
<link href="https://unpkg.com/[email protected]/dist/dropzone.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/[email protected]/dist/dropzone-min.js"></script>
That's it! You're ready to use DropzoneType in your forms.
The shortest thing that actually works, end to end. Four files, nothing optional. The Quick Start below is the same story with the entity relationship spelled out.
1. The file entity. getId(), getFilename() and getSrc() are read by the
widget. __toString() is required too: the widget renders the entity itself into
a hidden field, and PHP raises a fatal error without it.
// src/Entity/Attachment.php
#[ORM\Entity]
class Attachment implements \Stringable
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $filename = '';
#[ORM\Column(length: 255)]
private string $src = '';
public function getId(): ?int { return $this->id; }
public function getFilename(): string { return $this->filename; }
public function setFilename(string $filename): self { $this->filename = $filename; return $this; }
public function getSrc(): string { return $this->src; }
public function setSrc(string $src): self { $this->src = $src; return $this; }
public function __toString(): string { return (string) $this->id; }
}
2. The form field.
// src/Form/ArticleType.php
$builder->add('attachments', DropzoneType::class, [
'class' => Attachment::class,
'uploadHandler' => 'app_attachment_upload',
'removeHandler' => 'app_attachment_remove',
'maxFiles' => 10,
]);
3. The two routes. The upload handler must answer {"id": <int>}: that id is
what the widget puts in the hidden field. The remove handler receives the id in
the URL.
// src/Controller/AttachmentController.php
#[Route('/attachment/upload', name: 'app_attachment_upload', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function upload(Request $request, EntityManagerInterface $em): JsonResponse
{
$uploaded = $request->files->get('file');
// Never build the stored path from the client name: it is user input.
$storedName = bin2hex(random_bytes(8)).'.'.$uploaded->guessExtension();
$uploaded->move($this->getParameter('kernel.project_dir').'/public/uploads', $storedName);
$attachment = (new Attachment())
->setFilename($uploaded->getClientOriginalName())
->setSrc('/uploads/'.$storedName);
$em->persist($attachment);
$em->flush();
return $this->json(['id' => $attachment->getId()]);
}
#[Route('/attachment/{id}/remove', name: 'app_attachment_remove', methods: ['DELETE'])]
#[IsGranted('ROLE_USER')]
public function remove(Attachment $attachment, EntityManagerInterface $em): JsonResponse
{
// Read the id before the flush: Doctrine clears it on removal.
$id = $attachment->getId();
$em->remove($attachment);
$em->flush();
return $this->json(['id' => $id]);
}
4. The template. Dropzone.js must already be loaded, see Step 2.
{{ form_start(form) }}
{{ form_widget(form.attachments) }}
<button type="submit">Save</button>
{{ form_end(form) }}
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Attachment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $filename = '';
#[ORM\Column(length: 255)]
private string $src = ''; // URL or path to file
public function getId(): ?int
{
return $this->id;
}
public function getFilename(): string
{
return $this->filename;
}
public function setFilename(string $filename): self
{
$this->filename = $filename;
return $this;
}
public function getSrc(): string
{
return $this->src;
}
public function setSrc(string $src): self
{
$this->src = $src;
return $this;
}
}
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Post
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title = '';
// OneToMany relationship
#[ORM\OneToMany(targetEntity: Attachment::class, mappedBy: 'post', cascade: ['persist', 'remove'])]
private Collection $attachments;
public function __construct()
{
$this->attachments = new ArrayCollection();
}
public function addAttachment(Attachment $attachment): self
{
if (!$this->attachments->contains($attachment)) {
$this->attachments->add($attachment);
}
return $this;
}
public function removeAttachment(Attachment $attachment): self
{
$this->attachments->removeElement($attachment);
return $this;
}
public function getAttachments(): Collection
{
return $this->attachments;
}
}
namespace App\Form;
use App\Entity\Attachment;
use App\Entity\Post;
use Ethsam\SymfonyDropzone\Form\DropzoneType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PostFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Post Title',
])
->add('attachments', DropzoneType::class, [
'class' => Attachment::class,
'maxFiles' => 5,
'multiple' => true,
'uploadHandler' => 'app_upload_file',
'removeHandler' => 'app_remove_file',
'acceptedFiles' => 'image/*,.pdf',
'addRemoveLinks' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Post::class,
]);
}
}
namespace App\Controller;
use App\Entity\Attachment;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class FileController extends AbstractController
{
// These two routes are called by the widget, they are not protected by it.
// Add your own access control, and validate the upload server side: the
// acceptedFiles and maxFilesize options only filter in the browser.
#[IsGranted('ROLE_USER')]
#[Route('/upload', name: 'app_upload_file', methods: ['POST'])]
public function upload(Request $request, EntityManagerInterface $em): JsonResponse
{
$uploadedFile = $request->files->get('file');
if (!$uploadedFile) {
return new JsonResponse(['error' => 'No file provided'], 400);
}
// Never build the stored name from the client one, and never trust the
// client mime type. guessExtension() reads the actual file contents.
$filename = uniqid() . '.' . $uploadedFile->guessExtension();
$uploadedFile->move(
$this->getParameter('kernel.project_dir') . '/public/uploads',
$filename
);
// Create and persist the attachment
$attachment = new Attachment();
$attachment->setFilename($uploadedFile->getClientOriginalName());
$attachment->setSrc('/uploads/' . $filename);
$em->persist($attachment);
$em->flush();
return new JsonResponse(['id' => $attachment->getId()]);
}
// The id comes straight from the page, so check that this user is allowed
// to delete this particular file. Without that check any authenticated
// visitor can delete anyone's files by changing the id.
#[IsGranted('DELETE', subject: 'attachment')]
#[Route('/remove/{id}', name: 'app_remove_file', methods: ['DELETE'])]
public function remove(Attachment $attachment, EntityManagerInterface $em): JsonResponse
{
$id = $attachment->getId();
// Optionally delete the file from disk
// unlink($this->getParameter('kernel.project_dir') . '/public' . $attachment->getSrc());
$em->remove($attachment);
$em->flush();
return new JsonResponse(['id' => $id]);
}
}
{# templates/post/create.html.twig #}
{% extends 'base.html.twig' %}
{% block content %}
<h1>Create Post</h1>
{{ form_start(form) }}
{{ form_widget(form.title) }}
{{ form_widget(form.attachments) }}
<button type="submit">Create</button>
{{ form_end(form) }}
{% endblock %}
That's it! The bundle handles everything:
| Option | Type | Default | Description |
|---|---|---|---|
class |
string | null | Required. Entity class for file/attachment objects |
multiple |
bool | true | Enable multiple file mode; set false for single file (ManyToOne) |
maxFiles |
int | 1 | Maximum number of files allowed in the dropzone |
maxFilesize |
int|float|null | null | Maximum file size in MiB, rejected in the browser before upload. null keeps Dropzone's own default. This is a convenience for the user, not a security control: always enforce the limit again in your upload handler |
uploadHandler |
string | null | Required. Symfony route name for file upload endpoint |
removeHandler |
string | null | Required. Symfony route name for file removal endpoint |
uploadHandlerMethod |
string | "POST" | HTTP method for upload requests |
removeHandlerMethod |
string | "DELETE" | HTTP method for remove requests |
choice_src |
string | "src" | Entity property name containing file URL/path (getter method: get{PropertyName}()) |
acceptedFiles |
string | null | MIME types accepted (e.g., "image/*,.pdf") |
addRemoveLinks |
bool | true | Show "Remove" link on file previews |
headers |
array | [] | Custom HTTP headers sent with requests (e.g., ['Authorization' => 'Bearer TOKEN']) |
formData |
array | [] | Additional form data sent with upload request |
withCredentials |
int | 0 | XHR withCredentials setting (0 or 1) |
thumbnailWidth |
int | 120 | Width of preview thumbnails in pixels |
thumbnailHeight |
int | 120 | Height of preview thumbnails in pixels |
thumbnailMethod |
string | "crop" | Thumbnail scaling: "crop" or "contain" |
resizeWidth |
int | null | Client-side resize width before upload (preserves aspect ratio if only one set) |
resizeHeight |
int | null | Client-side resize height before upload |
resizeMimeType |
string | null | Output MIME type after resize (e.g., "image/jpeg") |
resizeMethod |
string | "contain" | Resize scaling: "crop" or "contain" |
filesizeBase |
int | 1024 | Base unit for filesize calculations |
ignoreHiddenFiles |
bool | true | Ignore hidden files in directories |
autoProcessQueue |
bool | true | Auto-process upload queue on file addition |
autoQueue |
bool | true | Auto-queue files when added to dropzone |
previewsContainer |
string | null | CSS selector for custom preview container (e.g., "#my-previews") |
required |
bool | true | Field is required for form validation |
Every option is type checked when the form is built. Passing a value of the wrong
type raises an InvalidOptionsException instead of being written into the page.
The widget dispatches DOM events on the dropzone element, so you can extend the
behaviour without overriding the template or injecting JavaScript from PHP.
Events bubble, so listening on document is enough.
| Event | Fired when | event.detail |
|---|---|---|
symfony-dropzone:init |
The Dropzone instance is ready | { dropzone, config } |
symfony-dropzone:sending |
Just before a file is uploaded | { dropzone, file, xhr, formData } |
symfony-dropzone:removedfile |
After a file is removed | { file } |
The symfony-dropzone: prefix is not an accident. Dropzone.js 6 dispatches its
own DOM events named dropzone:sending, dropzone:success and so on, with a
detail of {args: [...]}. Those are still available and unaffected; the
events above are the bundle's, with a named detail, and they work the same on
Dropzone 5 which has no DOM events at all.
The formData option covers values known when the form is built, in PHP. It
cannot carry anything the user types or picks on the page, because uploads are
sent by AJAX long after the page was rendered. That is what
symfony-dropzone:sending is for.
Say the same form has a category selector, and the upload handler needs to know which category was selected at the moment the file was dropped:
{{ form_row(form.category) }}
{{ form_widget(form.attachments) }}
<script>
document.addEventListener('symfony-dropzone:sending', function (event) {
var category = document.getElementById('{{ form.category.vars.id }}');
event.detail.formData.append('category', category.value);
});
</script>
The handler reads it like any other posted field:
public function upload(Request $request): JsonResponse
{
$category = $request->request->get('category');
$file = $request->files->get('file');
// ...
}
Treat that value as user input, exactly like the rest of the request. The browser decides what it sends, so validate it server side.
A value that has no field at all, computed in the browser, works the same way:
<script>
var uuid = crypto.randomUUID();
document.addEventListener('symfony-dropzone:sending', function (event) {
event.detail.formData.append('uuid', uuid);
});
</script>
Reaching the Dropzone instance itself, for anything the options do not cover:
<script>
document.addEventListener('symfony-dropzone:init', function (event) {
event.detail.dropzone.on('error', function (file, message) {
console.warn('upload failed', file.name, message);
});
});
</script>
The symfony-dropzone:init listener has to be registered before the widget renders,
typically in the page head or in a script loaded with defer. The other events
fire on user interaction, so they can be bound at any time.
Your file/attachment entity must implement:
getId(): ?int: Returns the unique identifiergetFilename(): string: Returns the filename for displaychoice_src property: By default getSrc(): string, returns the file URL/path for thumbnail display__toString(): string: Returns the identifier. The widget renders the
entity itself into the hidden field, so an entity that is not stringable makes
the form crash as soon as it holds existing files.Example minimal entity:
#[ORM\Entity]
class Attachment implements \Stringable
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $filename = '';
#[ORM\Column(length: 255)]
private string $src = '';
public function getId(): ?int { return $this->id; }
public function getFilename(): string { return $this->filename; }
public function setFilename(string $filename): self { $this->filename = $filename; return $this; }
public function getSrc(): string { return $this->src; }
public function setSrc(string $src): self { $this->src = $src; return $this; }
public function __toString(): string { return (string) $this->id; }
}
DropzoneType extends Symfony's form systemCollectionType with hidden inputs; for single: an EntityType fielduploadHandler route via AJAX{"id": <int>}DropzoneTransformer converts IDs back to entity objects via DoctrineremoveHandler route{"id": <int>}Step 2 is what actually detaches the file, and your handler is expected to make the removal effective on its own. Do not count on the form submission that follows to finish the job: files that were already attached when the page was rendered are also listed in hidden fields written by Symfony, which the widget does not remove. Their ids are still submitted after the preview disappears. If your remove handler only unlinks the file instead of deleting it, that submission will link it back.
$builder->add('attachments', DropzoneType::class, [
'class' => Attachment::class,
'multiple' => true,
'maxFiles' => 10,
'maxFilesize' => 50, // MiB, rejected in the browser before upload
'uploadHandler' => 'app_upload_file',
'removeHandler' => 'app_remove_file',
]);
$builder->add('profileImage', DropzoneType::class, [
'class' => ProfileImage::class,
'multiple' => false, // Single file mode
'maxFiles' => 1,
'uploadHandler' => 'app_upload_image',
'removeHandler' => 'app_remove_image',
]);
$builder->add('photos', DropzoneType::class, [
'class' => Photo::class,
'acceptedFiles' => 'image/*',
'maxFiles' => 5,
'uploadHandler' => 'app_upload_photo',
'removeHandler' => 'app_remove_photo',
'thumbnailWidth' => 200,
'thumbnailHeight' => 200,
'thumbnailMethod' => 'contain',
'resizeWidth' => 1920,
'resizeHeight' => 1080,
'resizeMethod' => 'contain',
'resizeMimeType' => 'image/jpeg',
]);
$builder->add('documents', DropzoneType::class, [
'class' => Document::class,
'uploadHandler' => 'api_upload_document',
'removeHandler' => 'api_remove_document',
'headers' => [
'Authorization' => 'Bearer ' . $this->apiToken,
],
'formData' => [
'documentType' => 'invoice',
],
]);
{# In template #}
<div id="my-previews"></div>
{{ form_start(form) }}
{{ form_widget(form.documents) }}
{{ form_end(form) }}
{# In form builder #}
$builder->add('documents', DropzoneType::class, [
'class' => Document::class,
'uploadHandler' => 'app_upload_document',
'removeHandler' => 'app_remove_document',
'previewsContainer' => '#my-previews',
]);
$builder->add('uploads', DropzoneType::class, [
'class' => Upload::class,
'uploadHandler' => 'app_upload_file',
'removeHandler' => 'app_remove_file',
'formData' => [
'category' => 'documents',
'userId' => $this->currentUser->getId(),
],
]);
Your upload handler receives this in $request->request->all():
public function upload(Request $request, EntityManagerInterface $em): JsonResponse
{
$category = $request->request->get('category'); // 'documents'
$userId = $request->request->get('userId');
$file = $request->files->get('file');
// ... handle upload
}
The widget never builds JavaScript out of your data. Everything the browser
needs, including stored filenames, is serialised to JSON inside an HTML
attribute and read back with JSON.parse. The inline <script> is a constant
block of code: it is byte for byte identical whatever your database contains.
This matters because filenames are supplied by whoever uploads a file. Versions up to and including 2.0.0 interpolated them straight into the script, where Twig's HTML escaping does not protect a JavaScript string context. See SECURITY.md for the details and for how to report an issue.
If you maintain a copy of dropzone.html.twig in your own
templates/bundles/SymfonyDropzoneBundle/, that copy is what Symfony renders,
and it is still on the old behaviour. Delete it or port your changes over.
maxFilesize and acceptedFiles are checked in the browser only. Enforce the
real limits in your upload handler.formData and headers are sent to your own upload
route, so do not put anything there you would not put in a form field.No option was renamed or removed, and no application code has to change.
<span hidden data-dropzone-config="..."> next
to the widget. If you have CSS or JavaScript selecting on sibling position
around the dropzone, check it.'5' instead
of 5 for maxFiles for instance, now raises an exception at form build time.If you're upgrading from the original emr-dev/symfony-dropzone:
Ethsam\SymfonyDropzone (was EmrDev\SymfonyDropzoneBundle)use Ethsam\SymfonyDropzone\Form\DropzoneType;Migration example:
// Before (v1)
use EmrDev\SymfonyDropzoneBundle\Form\DropzoneType;
// After (v2)
use Ethsam\SymfonyDropzone\Form\DropzoneType;
The API and functionality remain the same.
| Feature | ethsam/symfony-dropzone | symfony/ux-dropzone |
|---|---|---|
| Entity relationships | Full OneToMany/ManyToOne support | None; form values only |
| Data transformation | Automatic ID → Entity | Manual |
| Multiple files | Built-in with CollectionType | Not ideal |
| Edit mode pre-population | Yes; shows existing files | Manual templating |
| Upload handler | Simple route + JSON response | Requires UX component |
| File removal | Built-in DELETE handler | Manual |
| Dropzone config | Full access to all options | Limited |
| Learning curve | Minimal; standard Symfony forms | Moderate; UX paradigm |
| Maintenance | Active | Official but UX-focused |
Summary: Use ethsam/symfony-dropzone for entity-driven file management; use symfony/ux-dropzone if you need tight Stimulus integration or prefer the UX paradigm.
We welcome contributions! Please:
git checkout -b feat/your-featuregit commit -m "feat: add your feature"git push origin feat/your-featureFor bug reports or feature requests, please open an issue.
This bundle is licensed under the MIT License. See LICENSE for details.
Originally forked from emr-dev/symfony-dropzone.
maxFilesize option, and the reports that led to the 2.1.0 security reviewHow can I help you explore Laravel packages today?