enyo/dropzone
A Laravel package that integrates Dropzone.js for drag-and-drop file uploads, providing server-side upload handling, validation, and storage helpers so you can add multiple-file upload forms with progress feedback to your application quickly.
Know what you're installing first. enyo/dropzone is the Composer-packaged distribution of the Dropzone.js front-end library — not a Laravel service provider. There is no config/dropzone.php, no publishable migrations, no Dropzone facade, and no server-side upload handler shipped with it. The "Laravel-friendly API" is Laravel itself: a route, a FormRequest, and Storage. Budget an hour, not a sprint.
Install and get the assets in place.
composer require enyo/dropzone
Then either copy the dist files out of vendor/enyo/dropzone/dist/ or — strongly preferred if you already run Vite — install the npm package instead and import it:
// resources/js/app.js
import Dropzone from 'dropzone';
import 'dropzone/dist/dropzone.css';
Dropzone.autoDiscover = false; // see Gotchas
window.Dropzone = Dropzone;
Where to look first: vendor/enyo/dropzone/dist/ (dropzone.js, dropzone.min.js, dropzone.css, basic.css) and the upstream README in the package root. Treat the vendor README as the real documentation — it is the canonical Dropzone.js docs, not package-specific Laravel docs.
First use case — a Blade form posting to a plain Laravel route.
<meta name="csrf-token" content="{{ csrf_token() }}">
<form action="{{ route('uploads.store') }}" class="dropzone" id="mediaDropzone"></form>
// routes/web.php
Route::post('/uploads', [UploadController::class, 'store'])->name('uploads.store');
// UploadController
public function store(Request $request)
{
$request->validate([
'file' => ['required', 'file', 'mimes:jpg,jpeg,png,webp,pdf', 'max:10240'],
]);
$path = $request->file('file')->store('uploads', 'public');
return response()->json([
'path' => $path,
'url' => Storage::disk('public')->url($path),
]);
}
That's the whole loop: preview + progress bar from the JS, validation and storage from Laravel. Everything below is refinement of those two halves.
Configure via the options object, not inline attributes. The Dropzone.options.<elementId> convention is the idiomatic path and keeps JS out of Blade:
Dropzone.options.mediaDropzone = {
url: '/uploads',
paramName: 'file',
maxFilesize: 10, // MB
acceptedFiles: 'image/*,.pdf',
maxFiles: 5,
addRemoveLinks: true,
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content },
init() {
this.on('success', (file, res) => { file.serverPath = res.path; });
this.on('error', (file, msg) => console.warn(msg));
},
};
Note the id-to-key mapping: <form id="mediaDropzone"> becomes Dropzone.options.mediaDropzone.
Validation belongs in a FormRequest, and you should mirror it explicitly. The JS acceptedFiles/maxFilesize are UX hints only. Enforce mimes, max, and dimensions server-side. Return 422 with a JSON body so Dropzone can render the message on the preview rather than a bare red X.
Multi-file mode changes the payload shape. With uploadMultiple: true, Dropzone posts files[] and Laravel sees an array:
$request->validate([
'files' => ['required', 'array', 'max:5'],
'files.*' => ['file', 'mimes:jpg,png,pdf', 'max:10240'],
]);
foreach ($request->file('files') as $file) { $file->store('uploads', 'public'); }
$request->file('file') silently returns null in this mode — the single most common "it uploads but nothing is saved" bug.
Chunked uploads for anything large. Dropzone handles slicing client-side; you concatenate server-side. A pragmatic pattern is to append each chunk to a temp file keyed by dzchunkindex/dzuuid, then move it into place when dzchunkindex === dztotalchunkcount - 1:
{ chunking: true, forceChunking: true, chunkSize: 2_000_000, parallelChunkUploads: false, retryChunks: true }
Store chunks on a local disk (not S3) under chunks/{dzuuid} and clean up on completion or failure.
Multiple dropzones on one page requires manual instantiation, since autoDiscover would bind the same options everywhere:
new Dropzone('#cover', { url: '/cover', paramName: 'file' });
new Dropzone('#gallery', { url: '/gallery', paramName: 'files', uploadMultiple: true });
Livewire / Inertia / Alpine integration. Wrap the element in wire:ignore and re-initialise on the framework's lifecycle hook; destroy the instance on teardown (dz.destroy()) or you'll accumulate listeners and duplicate requests after every re-render. With Alpine, initialise in x-init and guard against double-init.
Attach domain context to the upload using params, so the server can validate ownership:
params: { gallery_id: {{ $gallery->id }} },
and validate with Rule::exists('galleries', 'id')->where('user_id', auth()->id()).
Deleting files is your job. Dropzone's addRemoveLinks only removes the preview. Wire removedfile to a DELETE route, and treat the DB row as the source of truth so orphans don't accumulate.
Hydrating existing files on page load is a standard trick for edit screens — call emit('addedfile', mockFile) and emit('thumbnail', mockFile, url) for each stored asset so the user sees what's already there.
php artisan vendor:publish for enyo/dropzone is wrong or describing a different, unmaintained wrapper. Plan for writing the controller yourself.headers, or add the endpoint to VerifyCsrfToken::$except if it uses a signed URL. Do not disable CSRF globally.maxFilesize unit mismatch. Dropzone's maxFilesize is megabytes; Laravel's max: rule is kilobytes. maxFilesize: 2 alongside max:2048 is correct; max:2 will reject almost everything with a message that looks like a bug.post_max_size is exceeded, Laravel receives an empty body and often reports a CSRF failure instead of a size error. Check upload_max_filesize, post_max_size, max_file_uploads, max_execution_time in php.ini, plus nginx client_max_body_size and fastcgi_read_timeout for chunked uploads.ValidationException (Laravel does this for you on JSON requests) and return ['error' => $message] or a message key consistently.Dropzone.autoDiscover = false must be set before any element with class dropzone is parsed, otherwise you get a phantom instance bound to defaults plus your configured one — doubled uploads. Turbo/Hotwire and Livewire navigation re-trigger auto-discovery, so disable it globally and init explicitly.<form>. Dropzone creates its own form element and hidden inputs; nested forms produce unpredictable submits and duplicate files.FormData-based fallbacks. If you also need a no-JS fallback, add a separate plain <input type="file" multiple> form rather than trying to reuse the same markup.acceptedFiles is not security. Browsers report MIME from extension; validate with mimes: (which inspects content) and consider dimensions: for images. Never store with the client-supplied filename — use store()/hashName().thumbnailWidth/thumbnailMethod rather than full-size previews, and revoke object URLs on removedfile.store('uploads', 'public') needs php artisan storage:link before Storage::url() resolves. For production volume, prefer direct-to-S3 with presigned URLs (uploadMultiple: false, one request per file) so large files never touch PHP memory.queuecomplete is the right hook for "save the collection" UX and for triggering a Livewire refresh — not success, which fires per file.NOASSERTION; Dropzone.js itself is MIT, but confirm the bundled licence file in vendor/enyo/dropzone/ since the manifest is ambiguous. Also note there are no Laravel version constraints because there's no PHP — Composer won't warn you about incompatibility, and equally won't upgrade you out of a problem.file vs files[]), then check the response body is JSON, then check Dropzone.options.<id> actually applied (log inside init). getRejectedFiles() and the error event with the full xhr argument will tell you whether the rejection was client-side (accept callback, size) or server-side.How can I help you explore Laravel packages today?