blueimp/jquery-file-upload
jQuery File Upload is a customizable jQuery plugin for multiple file selection, drag-and-drop, progress bars, image previews, validation, and chunked or resumable uploads. It works with any server backend and includes PHP examples.
This is a frontend-only jQuery widget — there is no service provider, no config/, no Composer package. "Leveraging it in Laravel" means writing a Laravel upload endpoint that speaks the plugin's JSON contract and wiring the widget into a Blade view. Everything server-side is yours.
1. Install the assets
npm i jquery blueimp-file-upload
// resources/js/app.js
import $ from 'jquery';
window.$ = window.$ || $;
window.jQuery = window.jQuery || $;
import 'blueimp-file-upload'; // core widget: $.fn.fileupload
import 'blueimp-file-upload/js/jquery.fileupload-ui'; // optional: table UI, progress, delete
import 'blueimp-file-upload/js/jquery.fileupload-validate';// optional: client-side validation
import 'blueimp-file-upload/js/jquery.fileupload-image'; // optional: image preview
Load the -ui, -validate, -image, -video, -process files after the core, and in that order where applicable (UI depends on the core template engine).
2. First route — a single endpoint that returns the expected JSON shape
// routes/web.php
Route::post('/uploads', [UploadController::class, 'store'])->name('uploads.store');
Route::delete('/uploads/{upload}', [UploadController::class, 'destroy'])->name('uploads.destroy');
class UploadController extends Controller
{
public function store(Request $request)
{
$request->validate([
'files' => ['required', 'array'],
'files.*' => ['file', 'max:10240', 'mimes:jpg,jpeg,png,webp,pdf'],
]);
$files = collect($request->file('files'))->map(function ($file) {
$path = $file->store('uploads', 'public');
return [
'name' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'type' => $file->getMimeType(),
'url' => Storage::disk('public')->url($path),
'thumbnailUrl' => Storage::disk('public')->url($path),
'deleteUrl' => route('uploads.destroy', ['upload' => $path]),
'deleteType' => 'DELETE',
];
});
return response()->json(['files' => $files]);
}
}
3. Blade markup — the widget binds to a <form> or <input type="file">, and reads the endpoint from data-url.
<form id="upload" method="POST" enctype="multipart/form-data"
data-url="{{ route('uploads.store') }}" data-delete-url="{{ route('uploads.destroy', ['upload' => '__path__']) }}">
@csrf
<input type="file" name="files[]" multiple accept="image/*,application/pdf">
<div class="progress"><div class="progress-bar" style="width:0"></div></div>
<div class="files"></div>
</form>
4. Initialize
$('#upload').fileupload({
dataType: 'json',
autoUpload: true,
maxFileSize: 10 * 1024 * 1024,
acceptFileTypes: /(\.|\/)(gif|jpe?g|png|webp|pdf)$/i,
formData: { _token: $('meta[name="csrf-token"]').attr('content') },
});
5. First realistic use case: a single avatar/cover-image field inside a profile form — preview via fileupload-image, client-side size/type pre-check, server validation, and a deleteUrl to remove the old file. That's the 80% case; everything else is variation on it.
Where to look first: the js/ folder (jquery.fileupload.js for options/events, jquery.fileupload-ui.js for the markup contract), then the wiki's Setup and Options pages. For Laravel specifically, the two things that matter are the response JSON shape and the CSRF/paramName details below.
The JSON contract is the integration. The UI plugin reads data.result.files; each entry should carry name, size, type, url, thumbnailUrl, deleteUrl, deleteType, and optionally error. On failure, the core plugin tries to read jqXHR.responseJSON.files[0].error, so returning a 422 with that shape surfaces your message in the UI instead of "File upload failed":
return response()->json(['files' => [['name' => 'x', 'error' => 'File too large']]], 422);
Laravel's default { message, errors } shape will not be shown to the user — either map it yourself or override via the fail callback.
Multi-file requests vs. one-per-request. Default singleFileUploads: true sends one request per file (the field is files[], so paramName is files[] — Laravel exposes it as $request->file('files'), an array). Set singleFileUploads: false to batch all files into one request when you want atomic "all or nothing" validation or a single DB record per submission.
Attaching extra context. Send the parent record ID, collection name, or tenant key alongside the file:
$('#upload').fileupload({
formData: { _token: '{{ csrf_token() }}', collection: 'gallery' },
});
// per-file, computed at send time
$('#upload').on('fileuploadsubmit', function (e, data) {
data.formData = { _token: '{{ csrf_token() }}', sort: data.context.length };
});
Chunked / resumable uploads for large files. Enable maxChunkSize (e.g. 5 * 1024 * 1024) and handle the Content-Range header. Each chunk arrives as a normal UploadedFile; you must reassemble:
public function store(Request $request)
{
$file = $request->file('files')[0];
$range = $request->header('Content-Range'); // "bytes 0-5242879/20971520"
if (! $range) {
return $this->storeWhole($file);
}
[$start, $total] = sscanf($range, 'bytes %d-%*d/%d');
$tmp = storage_path('app/chunks/'.md5($file->getClientOriginalName().$total));
file_put_contents($tmp, file_get_contents($file->getRealPath()), $start === 0 ? 0 : FILE_APPEND);
if (filesize($tmp) < $total) {
return response()->json(['files' => [['name' => $file->getClientOriginalName()]]]);
}
// final chunk: validate size/mime of the assembled file, then store
return $this->finalize($tmp, $file->getClientOriginalName());
}
Return 200/201 for every chunk (including intermediates) — the widget treats a non-2xx as terminal. Pair with maxRetries, retryTimeout, and limitConcurrentUploads: 1 for chunked mode; the plugin resumes from where it stopped on network drops within a session.
Presigned / direct-to-S3 alternative. If you're on S3, don't proxy bytes through PHP. Keep the widget for UI/validation but point url at a signed PUT/POST endpoint, or intercept in add/send and do your own fetch to the presigned URL, then POST metadata to Laravel. The plugin's add callback is the standard place to do this:
$('#upload').fileupload({
add: async function (e, data) {
const { url, fields } = await fetch('/api/presign', { method: 'POST', /* ... */ }).then(r => r.json());
data.url = url;
data.formData = fields;
data.submit();
},
});
Delete handling. The -ui plugin fires DELETE against deleteUrl. Make that route authorized ($this->authorize(...) or a signed URL) — the URL is client-visible and otherwise an open delete endpoint:
Route::delete('/uploads/{path}', [UploadController::class, 'destroy'])
->where('path', '.*')
->middleware('signed');
Composing with Blade components. Wrap the form in an anonymous Blade component (<x-file-upload :url="..." :multiple="true" />), keep the <script> init in a @push('scripts') block or in a small Alpine/vanilla module that re-initializes on Turbo/Livewire navigation. Avoid inlining init logic per page — you'll duplicate options.
Vite/jQuery setup. In vite.config.js you may need to alias jQuery to a single copy so the plugin augments the same $.fn your app uses:
resolve: { alias: { jquery: 'jquery/dist/jquery.js' } }
Progress and UI events. Drive your own Bootstrap/Tailwind progress bar from progressall, and use fileuploadstart/fileuploadstop to disable the submit button. fileuploaddone is where you should clear the native file input if replaceFileInput isn't already handling it.
Validation is two-layer, always. Client-side acceptFileTypes, maxFileSize, minFileSize are UX only. The server must re-validate mimes, max, and — for images — decode with getimagesize()/Image:: to reject polyglot files. With chunked uploads, max: validates each chunk, not the assembled file, so validate total size from the Content-Range denominator.
This library is archived. Last meaningful release is ~9.34; no security patches, no jQuery 4 support, no ESM-native build. Pin the version, and if the project is new, seriously weigh FilePond, Uppy, or Dropzone — they have Laravel/Vite-friendly maintenance and no jQuery dependency. Choose blueimp deliberately (existing app, legacy browser needs, the mature chunked-resumable implementation), not by default.
CSRF is the #1 first-run failure. The plugin sends a plain XHR; Laravel's VerifyCsrfToken will 419. Either pass _token in formData (shown above) or send the header globally:
$.ajaxSetup({ headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') } });
Note that formData is merged, not replaced, on fileuploadsubmit — assigning a new object there drops your token unless you re-add it.
Duplicate jQuery breaks $.fn.fileupload. If your app imports jQuery via Vite and the plugin resolves a second copy from node_modules, the widget attaches to a different instance and you get $(...).fileupload is not a function. Fix with the alias above plus window.$ = window.jQuery = $ before the plugin import. Same class of bug bites when a second script tag loads jQuery from a CDN.
paramName mismatch. Default is files[]; if you change it to files, $request->file('files') is a single UploadedFile instead of an array — your controller's ->map() will blow up. Pick one and keep it consistent between Blade, JS, and controller.
Return the right HTTP status for chunks. Intermediate chunks must be 2xx. Returning 201 only on the final chunk and 200 on intermediates is fine; returning 4xx on a duplicate/retried chunk (because your temp file already exists) will abort the whole upload. Make chunk handling idempotent.
Nginx and PHP limit files long before Laravel does. Check client_max_body_size, upload_max_filesize, post_max_size, and max_execution_time. A 413 from Nginx produces a raw HTML body, which the plugin's responseJSON parser can't read — you'll see "File upload failed" with no detail. Use maxChunkSize to stay under these limits and log the raw jqXHR.status/responseText in fileuploadfail while debugging.
acceptFileTypes matches the browser-reported MIME type, not the extension. HEIC, .docx from some browsers, and files with no MIME come through as application/octet-stream and get silently rejected. Keep the regex permissive and rely on server-side mimes:.
Preview memory leaks. The -image plugin creates object URLs; in SPAs/Livewire you must clean up on teardown:
$('#upload').fileupload('destroy');
Also cap previews with loadImageMaxFileSize / disableImageResize + imageMaxWidth/imageMaxHeight — generating thumbnails for a 40 MB photo in the browser will freeze the tab. Server-side thumbnails (Intervention/Glide) in thumbnailUrl are usually a better answer than client-side resizing.
maxChunkSize and S3 multipart minimums. S3 requires parts ≥ 5 MiB (except the last). A 1 MiB maxChunkSize will fail on real S3 multipart, and Laravel's Storage::putFile does not do multipart assembly for you — you either assemble locally then upload, or use presigned multipart URLs.
Debugging recipe. When it "just doesn't work": log data.files[0] in fileuploadadd (does the widget see the file?), check the Network tab for the actual POST body field name, and read jqXHR.responseJSON ?? jqXHR.responseText in fileuploadfail. The plugin's error messages are deliberately vague; the raw response is the only truth.
Useful event hooks as extension points: fileuploadadd (veto/intercept before send), fileuploadsend (mutate data.url/formData), fileuploadsubmit (per-file metadata), fileuploadchunksend / fileuploadchunkdone (chunk bookkeeping), fileuploaddone / fileuploadfail, and fileuploaddestroy for cleanup. Almost every custom behavior is a five-line handler on one of these rather than a fork.
replaceFileInput: true (the default) clones the file input after each upload so the same file can be re-selected — but it breaks any id-based selector or label binding you set up in Blade. Either disable it or re-bind listeners in fileuploaddone.
How can I help you explore Laravel packages today?