File upload
Single and multiple file upload: a drag-and-drop zone, a file list with previews, per-file progress, cancel and retry, saved files for edit forms, sorting, and avatar and compact looks. It works both in a Livewire component and in a classic form without Livewire.
Basic usage
model is the Livewire property the file is uploaded to. The file is dropped onto the zone or chosen by clicking it; the whole zone is a button and also opens with Enter or Space on the keyboard. The chosen file appears in the list below the zone with its preview (for images) or file type icon, its size and its status; a newly chosen file replaces the previous one. While the zone has focus, an image pasted from the clipboard is added too. Since there is no Livewire on this page, the upload is simulated; a file with "hata" in its name shows the error row.
<acun:file-upload model="id_document" label="Identity document" accept=".pdf,image/*" hint="Front of the ID card · PDF or image" />
| Prop | Values |
|---|---|
model | Livewire property (WithFileUploads) |
label | Label above the field |
title | Heading in the zone; default "Drop the file here" |
hint | Description; written from the limits when not given |
Multiple upload and limits
With multiple, several files can be chosen. Each file is uploaded separately and shows its own progress bar; the ones in the queue wait as "Waiting". A transfer can be cancelled while it runs, and retried after an error or cancellation. accept, max-size-kb and max-files are checked in the browser: a file that does not fit is not uploaded, appears in the list with the reason ("too large (up to 2 MB)", "this file type is not accepted", "up to 5 files") and can be dismissed. When hint is not given, the description is written from these limits. These checks are only quick feedback; write the same rules on the server as well (see "Livewire component" below).
<acun:file-upload model="documents" multiple accept=".pdf,.jpg,.png" :max-size-kb="2048" :max-files="5" label="Ticket documents" />
| Prop | Values |
|---|---|
multiple | true | false |
accept | .pdf,.jpg | image/* | application/pdf |
max-size-kb | Per-file limit in KB |
max-files | Maximum number of files in the list (including saved ones) |
Saved files (edit form)
Pass previously saved files with existing; each one carries id, name, url, size (bytes) and an optional thumbnail (thumbnail URL). They appear in the same list as new files, their names open in a new tab, and they count toward the max-files limit. The remove button dispatches the file-upload-remove-existing event with { model, name, id }; if remove-existing is given, that Livewire method is also called with the id.
<acun:file-upload
model="documents"
multiple
:max-files="5"
:existing="$savedDocuments"
remove-existing="removeSavedDocument"
/>
| Prop | Values |
|---|---|
existing | [['id' => 7, 'name' => …, 'url' => …, 'size' => …, 'thumbnail' => …]] |
remove-existing | Livewire method called with the id |
Sorting
With sortable, the files in a multiple list are reordered by dragging the handle (mouse and touch) or with the up/down buttons on the row (keyboard). On every change, the file-upload-sorted event is dispatched with { model, name, order }; if sort-method is given, the Livewire method is called with the new order. The order items are { type: 'existing', id } for a saved file and { type: 'upload', id } for a temporary upload (id is the file's getFilename() value). In a form without Livewire, the files are added to the form field in list order.
<acun:file-upload model="photos" multiple sortable sort-method="sortPhotos" accept="image/*" :existing="$savedPhotos" />
| Prop | Values |
|---|---|
sortable | true | false (multiple only) |
sort-method | Livewire method called with the new order |
Avatar
variant="avatar" draws a round picker for a single image: a preview with "Change" and "Remove" buttons; the upload percentage appears over the image. When accept is not given, image/* is used, and multiple is ignored. Pass the saved photo with existing; without a thumbnail, the url becomes the preview.
<acun:file-upload model="profile_photo" variant="avatar" label="Profile photo" :max-size-kb="1024" :existing="$savedPhoto" remove-existing="removePhoto" />
| Prop | Values |
|---|---|
variant | 'default' | 'avatar' | 'compact' |
Compact
variant="compact" is a single-line picker for tight forms: the button, file name, status and remove button sit on the same row; files can be dropped onto the row. When used with multiple, the row shows the number of files and the files are listed below.
<acun:file-upload model="invoice" variant="compact" label="Invoice" accept=".pdf" :max-size-kb="2048" />
Taking a photo
When camera is given, a "Take a photo" button appears on touch devices such as phones and tablets; a photo taken with the rear camera is added straight to the list. The button does not appear on devices used with a mouse.
<acun:file-upload model="return_photo" accept="image/*" camera hint="Photo of the damaged product" />
| Prop | Values |
|---|---|
camera | true | false |
Validation errors
The component shows the model's validation errors itself: a documents error below the field, and an error for a single file such as documents.2 on that file's row (in Livewire, the file at index 2 of the property). On a single-file field, the error appears directly on the file row. If you show the messages on the page yourself, pass :show-errors="false".
<acun:file-upload model="documents" multiple accept=".pdf" :max-files="5" />
| Prop | Values |
|---|---|
show-errors | true | false |
Livewire component
Use WithFileUploads in the component and define a property with the same name as model; for a multiple field, make it an empty array (public array $documents = []), and each file is uploaded separately and added to this array. Files first go to Livewire's temporary folder. Validation in the updated hook shows the error on the file's row as soon as the upload finishes. When saving, move the files to the permanent disk with store() and reset the property; since the list follows the server, it empties by itself. For a temporary file removed from the list, the component calls $wire.removeUpload() (on a single-file field it sets the property to null); you do not need to write a separate method.
use App\Models\Ticket;
use Illuminate\Support\Facades\Storage;
use Livewire\Component;
use Livewire\WithFileUploads;
class TicketDocuments extends Component
{
use WithFileUploads;
public Ticket $ticket;
/** @var array<int, \Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
public array $documents = [];
/** @var array<int, int> Saved documents to delete on save */
public array $toDelete = [];
protected function rules(): array
{
return [
// 5 documents in total: saved ones count, the ones to delete do not.
'documents' => ['array', 'max:'.max(0, 5 - $this->saved()->count())],
'documents.*' => ['file', 'mimes:pdf,jpg,png', 'max:2048'],
];
}
public function updatedDocuments(): void
{
$this->validate(['documents.*' => $this->rules()['documents.*']]);
}
public function removeSavedDocument(int $id): void
{
$this->toDelete[] = $id;
}
public function save(): void
{
$this->validate();
$this->ticket->documents()->whereKey($this->toDelete)->get()->each->delete();
foreach ($this->documents as $document) {
$this->ticket->documents()->create([
'name' => $document->getClientOriginalName(),
'path' => $document->store('tickets/'.$this->ticket->id, 'public'),
'size' => $document->getSize(),
]);
}
$this->reset('documents', 'toDelete');
}
private function saved()
{
return $this->ticket->documents()->whereKeyNot($this->toDelete);
}
public function render()
{
return view('livewire.ticket-documents', [
'savedDocuments' => $this->saved()->get()->map(fn ($document) => [
'id' => $document->id,
'name' => $document->name,
'url' => Storage::disk('public')->url($document->path),
'size' => $document->size,
]),
]);
}
}
Saving the order
sort-method receives the new order on every sort. You can write the order of saved files right away, and arrange temporary uploads in the same order so they are stored in that order on save. Always limit the ids to your own record.
public function sortPhotos(array $order): void
{
foreach ($order as $position => $item) {
if ($item['type'] === 'existing') {
$this->product->photos()->whereKey($item['id'])->update(['position' => $position]);
}
}
// Temporary uploads in the same order too (id = getFilename()).
$uploadOrder = collect($order)->where('type', 'upload')->pluck('id')->flip();
$this->photos = collect($this->photos)
->sortBy(fn ($photo) => $uploadOrder[$photo->getFilename()] ?? PHP_INT_MAX)
->values()
->all();
}
Classic form (without Livewire)
When name is given instead of model, nothing is uploaded in advance: the chosen files are kept in a hidden <input type="file" name="attachments[]"> field and go to the server when the form is submitted. The list can be edited before submitting; added and removed files are reflected in the field right away. The form must have enctype="multipart/form-data". The ids of removed saved files are sent in hidden fields named with removed-name (removed_attachments[]). When the form is reset (reset), the list is cleared too.
<form method="POST" action="{{ route('tickets.update', $ticket) }}" enctype="multipart/form-data" class="space-y-4">
@csrf
@method('PUT')
<acun:file-upload name="attachments" multiple accept=".pdf,.jpg,.png" :max-size-kb="2048" :max-files="5" :existing="$savedAttachments" removed-name="removed_attachments" />
<acun:button type="submit">Save</acun:button>
</form>
| Prop | Values |
|---|---|
name | Form field; [] is appended when multiple |
removed-name | Hidden field for the ids of removed saved files |
Classic form: controller
Validate on the server: the file type and size should match accept and max-size-kb (the max: rule takes KB); the number of files is calculated by subtracting the saved files from the max-files limit. After a validation error, the browser cannot restore the chosen files; attachments and attachments.* errors are listed below the component.
public function update(Request $request, Ticket $ticket): RedirectResponse
{
$removed = $request->input('removed_attachments', []);
$remaining = 5 - $ticket->attachments()->whereKeyNot($removed)->count();
$request->validate([
'attachments' => ['array', 'max:'.max(0, $remaining)],
'attachments.*' => ['file', 'mimes:pdf,jpg,png', 'max:2048'],
'removed_attachments' => ['array'],
'removed_attachments.*' => ['integer'],
]);
$ticket->attachments()->whereKey($removed)->get()->each->delete();
foreach ($request->file('attachments', []) as $attachment) {
$ticket->attachments()->create([
'name' => $attachment->getClientOriginalName(),
'path' => $attachment->store('tickets/'.$ticket->id),
'size' => $attachment->getSize(),
]);
}
return back();
}
Upload state and events
Since the component dispatches Livewire's upload events (livewire-upload-start, -finish, -error) itself, wire:loading and wire:target work with the model name; you can lock the save button until the queue finishes. If the file-upload-reset event is dispatched on the window with { model } ({ name } in a classic form), the running upload is cancelled and waiting and failed rows are cleared (e.g. when a modal closes).
<acun:file-upload model="documents" multiple />
<div class="mt-4 flex justify-end gap-2">
<acun:button variant="white" x-data x-on:click="$dispatch('file-upload-reset', { model: 'documents' })">Cancel</acun:button>
<acun:button wire:click="save" wire:loading.attr="disabled" wire:target="documents,save">Save</acun:button>
</div>
| Prop | Values |
|---|---|
file-upload-reset | Dispatched on the window; { model: 'documents' } |
file-upload-remove-existing | { model, name, id } |
file-upload-sorted | { model, name, order } |
Uploading without Livewire (advanced)
To upload with model on a page without a Livewire component, register your own transport (e.g. directly to a storage service). upload returns { promise, cancel } for each file; the promise resolves with the file's id, and if it is rejected with an error carrying cancelled: true, the row is marked as cancelled. concurrency is how many files are sent at the same time. With Livewire, files are sent one after another; Livewire processes uploads to the same property one by one.
import { registerFileUpload } from '@acunsoft/acun-ui-pro';
document.addEventListener('alpine:init', () => {
registerFileUpload(window.Alpine, {
transport: {
concurrency: 3,
upload(file, { onProgress }) {
const request = new XMLHttpRequest();
const promise = new Promise((resolve, reject) => {
request.upload.onprogress = (event) => onProgress((event.loaded / event.total) * 100);
request.onload = () => (request.status < 300 ? resolve(JSON.parse(request.response).id) : reject(new Error('failed')));
request.onerror = () => reject(new Error('failed'));
request.onabort = () => reject(Object.assign(new Error('cancelled'), { cancelled: true }));
});
const body = new FormData();
body.append('file', file);
request.open('POST', '/uploads');
request.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name=csrf-token]').content);
request.send(body);
return { promise, cancel: () => request.abort() };
},
},
});
});
Props and slots
The values the component accepts.
| Prop | Default | Description |
|---|---|---|
model | null | Livewire property the files are uploaded to (WithFileUploads); either model or name is required |
name | null | Without Livewire: name of the form field; the files are submitted with the form |
multiple | false | Multiple files |
variant | 'default' | 'default' | 'avatar' (single round image) | 'compact' (single line) |
accept | '' | Such as '.pdf,.jpg' or 'image/*'; checked in the browser |
max-size-kb | null | Per-file limit in KB; checked in the browser |
max-files | null | Maximum number of files in a multiple list (including saved ones) |
remaining | null | Number of files that can still be added in multiple mode (legacy; use max-files instead) |
existing | [] | Saved files: id, name, url, size, thumbnail |
remove-existing | null | Livewire method called with the id when a saved file is removed |
removed-name | null | Without Livewire: name of the hidden field for the ids of removed saved files |
sortable | false | Reordering by dragging and with buttons (multiple) |
sort-method | null | Livewire method called with the new order |
label | null | Label above the field |
required | false | Adds * to the label; screen readers hear "required" in the name of the choose button. Write the rule on the server as well |
title | null | Heading in the zone; defaults to "Drop the file here" / "Drop files here" |
hint | null | Description; written from the limits when not given |
camera | false | "Take a photo" button on touch devices |
show-errors | true | Shows model and model.* validation errors |