symfony/ux-dropzone
Symfony UX Dropzone adds lightweight drag-and-drop file uploads to Symfony Forms, letting visitors drop files into a container instead of browsing their computer. Part of the Symfony UX initiative.
Confirm you're in a Symfony app (or a Symfony-based package), not a Laravel one.
symfony/ux-dropzone is a Symfony UX bundle — it ships an extra Form type plus a Stimulus controller and a Twig form theme. There is no Laravel service provider, no Blade integration, and no laravel/symfony-bridge. Inside a Laravel application the package will install but do nothing; Laravel projects should use dropzone (npm) directly or a Filament/Livewire upload component instead.
Install the bundle (Flex handles the wiring):
composer require symfony/ux-dropzone
The Flex recipe registers the bundle, adds the dropzone Stimulus controller to assets/controllers.json, and registers the form theme. Verify with php bin/console debug:stimulus (controller present) and php bin/console debug:asset-map (JS in the map).
Install the frontend package:
php bin/console importmap:require @symfony/ux-dropzone # AssetMapper
# or
npm install -D @symfony/ux-dropzone # Webpack Encore / Vite
Use the type in a form — it extends FileType, so all standard file options still apply:
use Symfony\UX\Dropzone\Form\DropzoneType;
$builder->add('avatar', DropzoneType::class, [
'label' => 'Drag your photo here',
'constraints' => [
new Assert\File(maxSize: '2M', mimeTypes: ['image/jpeg', 'image/png']),
],
]);
Render it and handle the upload — the form theme produces the dropzone markup automatically, so form_row() is enough:
{{ form_start(form) }}
{{ form_row(form.avatar) }}
<button>Save</button>
{{ form_end(form) }}
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile $file */
$file = $form->get('avatar')->getData();
$file->move($this->getParameter('avatars_dir'), $file->getClientOriginalName());
}
Replace a plain <input type="file"> on a profile/settings page with a drag-and-drop zone, while keeping Symfony's validation pipeline as the source of truth for allowed MIME types and size. The browser is a convenience layer; the File constraint still rejects bad uploads server-side.
Keep validation out of the JS: pass maxSize/mimeTypes only through the File constraint so client hints and server rules can't drift apart. If you want the zone to show a friendly limit, mirror the same value in attr:
$builder->add('cv', DropzoneType::class, [
'constraints' => [new Assert\File(maxSize: '5M', mimeTypes: ['application/pdf'])],
'attr' => ['data-dropzone-max-size' => '5M'],
]);
DropzoneType is a FileType, so multiple => true produces an array of UploadedFile. The common pattern is a mapped CollectionType of file entities, or an unmapped multiple file field processed in the controller:
$builder->add('attachments', DropzoneType::class, [
'multiple' => true,
'mapped' => false,
'constraints' => [new Assert\All([new Assert\File(maxSize: '10M')])],
]);
// controller
foreach ($form->get('attachments')->getData() as $file) {
$entity->addAttachment(Attachment::fromUpload($file));
}
For large files or immediate previews, point the dropzone at a dedicated upload controller (POST /uploads, returns JSON {id, url}), store the file, and keep only the returned identifier on the form (a hidden field or EntityType). This decouples the browser's upload from form submit and gives you a place to enforce auth, virus scanning, and quota limits.
Extend the shipped Stimulus controller rather than re-initialising Dropzone on the same element:
// assets/controllers/my_dropzone_controller.js
import DropzoneController from '@symfony/ux-dropzone/controller';
import { Controller } from '@hotwired/stimulus';
export default class extends DropzoneController {
connect() {
super.connect();
this.dropzone.options.maxFiles = 5;
this.dropzone.options.acceptedFiles = 'image/*';
}
}
Override the template with data-controller="my-dropzone" in attr, and keep the bundle's controller registered so its previewTemplate/i18n defaults stay intact.
The bundle ships a form theme; if you have a custom form theme, ensure it uses the dropzone theme (or that form_themes in twig.yaml lists it) or you'll silently get a bare file input. Override the widget by defining _dropzone_widget.html.twig in your own theme block, not by copying the vendor file.
Uploads are testable with KernelBrowser::submit()'s file arguments, or by constructing a UploadedFile from a fixture path (new UploadedFile($path, 'cv.pdf', 'application/pdf', null, true)) and submitting the form directly via $form->submit([...]). Dropzone's JS behaviour isn't exercised by Symfony's functional tests — the non-JS fallback path is what you're actually asserting.
symfony/asset-mapper 8.2 and later. If your app uses AssetMapper, a future composer update that resolves asset-mapper to 8.2+ will now fail with a dependency conflict instead of failing at runtime. Pin symfony/asset-mapper below 8.2 (or upgrade symfony/ux-dropzone when the constraint is lifted). This is the only item in this release with day-to-day impact; the rest is tooling dependency bumps and a docs-only change replacing a hardcoded version with an X.Y.Z placeholder in the assets README.twig.form_themes and that the recipe's form_themes entry wasn't removed by a manual twig.yaml.@symfony/ux-dropzone is in importmap.php; with Encore, confirm the package is imported in app.js and that controllers.json has the dropzone entry with "enabled": true. php bin/console debug:stimulus is faster than grepping build output.maxFiles/size hints don't change upload_max_filesize, post_max_size, or max_file_uploads. A large multi-file submit silently truncates to max_file_uploads — validate the count with Count on the field if that matters.chunking reassembles server-side from separate PHP requests; PHP still enforces post_max_size per chunk, and you must handle reassembly yourself. Don't enable chunking without an async upload endpoint.accept attribute.DropzoneType only exposes inherited FileType options; anything dropzone-specific (previews, autoProcessQueue, dict defaults) lives in JS via inherited controller methods or data-* attributes.How can I help you explore Laravel packages today?