Livewire file upload with progress and drag and drop
A drag-and-drop Livewire upload with a progress bar and no Alpine: files checked on the server, stored privately, and Livewire's 12 MB limit raised.
php artisan bladewell:add file-upload progress button toast
Uses File upload, Progress, Button and Toast. New to Bladewell? Install it first.
The field
Bladewell's file upload is a real file input inside a drop area: drag files onto it, or browse. With multiple it lists the files, each with a remove button. accept and max-size turn the wrong files away as they're picked, and say why. Bound with wire:model, the field sends the files to Livewire as soon as they're picked, and the property always holds exactly what the list shows. To keep it so, every pick or remove sends the whole list again, so the bar shows that whole batch; keep max-files and max-size sensible.
The field doesn't draw upload progress itself. Livewire fires its upload events on the input, and a progress bar follows them with a short script.
The component
Check every file on the server: the browser's checks are a convenience, and anyone can send any file. File::types() looks at what's inside the file, not its name. Store each under a generated name on the private local disk, and show them through a route that checks who's asking.
namespace App\Livewire;
use App\Models\Project;
use Illuminate\Validation\Rules\File;
use Livewire\Component;
use Livewire\WithFileUploads;
class UploadDocuments extends Component
{
use WithFileUploads;
public Project $project;
// Exactly what the list shows: removing a file from the list removes it here too.
public array $documents = [];
public function save(): void
{
$this->authorize('update', $this->project);
// The browser's accept and max-size are for convenience; these are the real checks. File::types reads the
// file's content, not its name. 10 * 1024 kilobytes: the field's max-size="10MB" counts 1024s, and
// File's '10mb' counts 1000s, which would turn away a file the field let through.
$this->validate([
'documents' => ['required', 'array', 'max:5'],
'documents.*' => [File::types(['pdf', 'png', 'jpg', 'jpeg'])->max(10 * 1024)],
]);
foreach ($this->documents as $document) {
$this->project->documents()->create([
// A generated name on the private disk; show them through a route that checks who's asking.
'path' => $document->store('documents', 'local'),
'name' => $document->getClientOriginalName(),
'size' => $document->getSize(),
]);
}
// Empties the list, and the files' previews, too.
$this->reset('documents');
$this->dispatch('toast', type: 'success', message: 'Documents uploaded.');
}
public function render()
{
return view('livewire.upload-documents');
}
}
The view
The wrapper's data-upload-progress names the bar that follows this field. The bar sits in wire:ignore, because the script, not a render, decides what it shows.
<form wire:submit="save" class="grid max-w-xl gap-5">
{{-- data-upload-progress names the bar that follows this field's uploads (see upload-progress.js). --}}
<div data-upload-progress="documents-progress">
<x-widget.file-upload label="Documents" wire:model="documents" multiple max-files="5" accept=".pdf,.png,.jpg,.jpeg" max-size="10MB" />
</div>
{{-- wire:ignore: the script drives the bar, so a render must leave it alone. Hidden until an upload starts. --}}
<div wire:ignore>
<div data-upload-progress-bar hidden>
<x-widget.progress id="documents-progress" label="Uploading" show-label />
</div>
</div>
<x-widget.button type="submit">Upload</x-widget.button>
</form>
The progress bar
Livewire fires livewire-upload-start, -progress and -finish on the file input while it uploads. The script shows the bar when one starts, moves it with progress.set(), and hides it when the last running upload ends. Screen readers read its percentage when someone moves to it; it isn't announced as it changes, so the field's own list says when files are added. No Alpine is needed.
// resources/js/upload-progress.js, imported from resources/js/app.js. While Livewire uploads, it fires
// livewire-upload-start, -progress and -finish on the file input; this shows them on the bar the field names.
function barFor(event) {
const id = event.target.closest?.('[data-upload-progress]')?.dataset.uploadProgress;
return id ? document.getElementById(id) : null;
}
// Uploads still running per bar: picking again while one runs starts another, and the bar stays until both end.
const running = new WeakMap();
document.addEventListener('livewire-upload-start', (event) => {
const bar = barFor(event);
if (bar) {
running.set(bar, (running.get(bar) ?? 0) + 1);
progress.set(bar, 0);
bar.closest('[data-upload-progress-bar]').hidden = false;
}
});
document.addEventListener('livewire-upload-progress', (event) => {
const bar = barFor(event);
if (bar) {
progress.set(bar, event.detail.progress);
}
});
// Done, failed or cancelled: the field itself lists the files, and shows any error under them.
['finish', 'error', 'cancel'].forEach((name) => {
document.addEventListener(`livewire-upload-${name}`, (event) => {
const bar = barFor(event);
if (bar) {
const left = Math.max(0, (running.get(bar) ?? 1) - 1);
running.set(bar, left);
bar.closest('[data-upload-progress-bar]').hidden = left > 0 ? false : true;
}
});
});
Files bigger than 12 MB
Before your component sees a file, Livewire checks it with its own rule: 12 MB at most, unless you raise it, whatever your max says. PHP has limits of its own as well: upload_max_filesize and post_max_size in php.ini, and your web server may have one too.
// config/livewire.php (php artisan livewire:publish --config)
return [
// ...
// Livewire checks every file it takes in first, with its own rule: 12 MB at most unless you say otherwise,
// whatever your component allows. For bigger files, raise it to your largest: here 50 MB, in kilobytes.
'temporary_file_upload' => [
'rules' => ['required', 'file', 'max:51200'],
],
];
Livewire cleans up temporary uploads more than a day old, when a later upload comes in, so a file picked but never saved doesn't stay. That's on a local disk; on S3, set an expiry rule with php artisan livewire:configure-s3-upload-cleanup. For forms without Livewire, the file upload sends each file to your own route as soon as it's picked, with a progress bar per file and retry: see upload-url on the file upload's page.