How to upload multiple files to S3 with Livewire and Alpine.js on Laravel Vapor
You know why you are here.
Let's get to it.
Problem
You want to upload multiple files to S3 with Livewire and Alpine.js, but you get the dreaded S3DoesntSupportMultipleFileUploads
error.
Because, guess what, you can't actually upload multiple fields to S3 at once.
You have to upload them one by one, but that is pain in the ass to do.
So here is a copy-pastable Alpine.js component that you can use.
Solution
Alpine Component
document.addEventListener('alpine:init', () => {
Alpine.data('uploader', ({ multiple = true, files = [] } = {}) => ({
counter: 1,
dragging: false,
multiple: multiple,
uploadedFiles: files,
resetFiles() {
this.counter = 1
this.dragging = false
this.uploadedFiles = []
},
onFileDropped(event) {
this._uploadOneOrMultiple(event.dataTransfer.files)
},
onFileInputChanged(event) {
this._uploadOneOrMultiple(event.target.files)
},
removeFile(index) {
this.uploadedFiles.splice(index, 1)
},
getFileById(id) {
return this.uploadedFiles.find((f) => f.id === id)
},
_uploadOneOrMultiple(files) {
this.resetFiles()
const fileArray = Array.from(files)
const uploadPromises = this.multiple ? fileArray : fileArray.slice(0, 1)
Promise.all(uploadPromises.map((file) => this._uploadFile(file))).then(
() => {
this.$wire.set('startUpload', this.uploadedFiles)
}
)
},
_uploadFile(file) {
const id = ++this.counter
this._initFileUpload(id, file)
return new Promise((resolve, reject) => {
Vapor.store(file, {
visibility: 'private',
progress: (progress) => this._updateProgress(id, progress),
})
.then((response) => this._handleSuccess(id, file, response, resolve))
.catch((error) => this._handleFailure(id, error, reject))
})
},
_initFileUpload(id, file) {
this.uploadedFiles.push({
id,
progress: 0,
error: false,
name: file.name,
content_type: file.type,
})
},
_updateProgress(id, progress) {
const file = this.getFileById(id)
file.progress = Math.round(progress * 100)
file.error = false
},
_handleSuccess(id, file, response, resolve) {
if (!response) return
const updatedFile = {
...this.getFileById(id),
uuid: response.uuid,
key: response.key,
bucket: response.bucket,
name: file.name,
content_type: file.type,
visibility: 'private',
}
this.uploadedFiles = this.uploadedFiles.map((file) =>
file.id === id ? updatedFile : file
)
resolve(updatedFile)
},
_handleFailure(id, error, reject) {
const file = this.getFileById(id)
file.progress = 100
file.error = true
},
}))
})
If you want to put it inside a seperate .JS file, you have to do this instead:
import {
Livewire,
Alpine,
} from '../../vendor/livewire/livewire/dist/livewire.esm'
import dropdown from './uploader.js'
Alpine.data('uploader', uploader)
window.Vapor = Vapor
window.Alpine = Alpine
// Start Livewire
Livewire.start()
export default ({ multiple = true, files = [] } = {}) => ({
counter: 1,
dragging: false,
multiple: multiple,
uploadedFiles: files,
resetFiles() {
this.counter = 1
this.dragging = false
this.uploadedFiles = []
},
onFileDropped(event) {
this._uploadOneOrMultiple(event.dataTransfer.files)
},
onFileInputChanged(event) {
this._uploadOneOrMultiple(event.target.files)
},
removeFile(index) {
this.uploadedFiles.splice(index, 1)
},
getFileById(id) {
return this.uploadedFiles.find((f) => f.id === id)
},
_uploadOneOrMultiple(files) {
this.resetFiles()
const fileArray = Array.from(files)
const uploadPromises = this.multiple ? fileArray : fileArray.slice(0, 1)
Promise.all(uploadPromises.map((file) => this._uploadFile(file))).then(
() => {
this.$wire.set('startUpload', this.uploadedFiles)
}
)
},
_uploadFile(file) {
const id = ++this.counter
this._initFileUpload(id, file)
return new Promise((resolve, reject) => {
Vapor.store(file, {
visibility: 'private',
progress: (progress) => this._updateProgress(id, progress),
})
.then((response) => this._handleSuccess(id, file, response, resolve))
.catch((error) => this._handleFailure(id, error, reject))
})
},
_initFileUpload(id, file) {
this.uploadedFiles.push({
id,
progress: 0,
error: false,
name: file.name,
content_type: file.type,
})
},
_updateProgress(id, progress) {
const file = this.getFileById(id)
file.progress = Math.round(progress * 100)
file.error = false
},
_handleSuccess(id, file, response, resolve) {
if (!response) return
const updatedFile = {
...this.getFileById(id),
uuid: response.uuid,
key: response.key,
bucket: response.bucket,
name: file.name,
content_type: file.type,
visibility: 'private',
}
this.uploadedFiles = this.uploadedFiles.map((file) =>
file.id === id ? updatedFile : file
)
resolve(updatedFile)
},
_handleFailure(id, error, reject) {
const file = this.getFileById(id)
file.progress = 100
file.error = true
},
})
Livewire Component
<?php
namespace App\Livewire;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Component;
class ExampleUpload extends Component
{
public bool $multiple = true;
public array $uploadedFiles = [];
public function startUpload()
{
foreach ($this->uploadedFiles as $file) {
// Generate random filename, whatever, unimportant.
$path = sprintf('/documents/%s_%s', time(), Str::replace(' ', '_', $file['name']));
// Copy the file from the temporary directory to the new filepath
if (Storage::copy($file['key'], $path) === false) {
// Upload failed...
return null;
}
// Store the new filepath and metadata in database
$document = Document::create([
'path' => $path,
'name' => $file['name'],
'mime' => $file['content_type'] ?? null,
'size' => Storage::size($path),
]);
}
}
public function render()
{
return view('livewire.example-upload');
}
}
Livewire blade file
<div
class="mx-auto max-w-lg"
x-data="uploader({
multiple: $wire.entangle('multiple'),
files: $wire.entangle('uploadedFiles'),
})"
>
<div>
<button wire:click="$toggle('multiple')">
toggle multiple file uploads
</button>
<div class="mb-4 bg-white p-2">
<div class="mb-2">Livewire State</div>
<div>
<pre class="border border-gray-200 bg-gray-100 p-2">
{{ json_encode($uploadedFiles, JSON_PRETTY_PRINT) }}</pre
>
</div>
</div>
<div class="mb-4 bg-white p-2">
<div class="mb-2">Alpine State</div>
<div>
<pre
class="border border-gray-200 bg-gray-100 p-2"
x-text="JSON.stringify(uploadedFiles, null, 2)"
></pre>
</div>
</div>
</div>
<div class="flex flex-col gap-8" x-id="['file-input']">
<div>
<label
x-bind:for="$id('file-input')"
x-on:dragover.prevent="$event.dataTransfer.dropEffect = 'move'"
x-on:drop.prevent="onFileDropped"
class="flex items-center justify-center rounded-lg border-4 border-dashed border-gray-200 bg-gray-50 p-12"
>
<span>drop files here</span>
</label>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<label class="mb-2 block text-sm text-gray-700">
<span x-show="multiple">Upload multiple files here</span>
<span x-show="!multiple">Upload single files here</span>
</label>
<input
x-bind:id="$id('file-input')"
type="file"
x-bind:multiple="multiple"
x-on:change="onFileInputChanged"
/>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<span class="mb-2 block text-sm text-gray-700"> File progress here </span>
<div class="mt-4 w-full" x-show="uploadedFiles.length" x-cloak>
<ul
role="list"
class="divide-y divide-gray-200 rounded-md border border-gray-200"
>
<template x-for="(file, index) in uploadedFiles" :key="file.id">
<li class="relative">
<div
class="absolute inset-0 z-10 bg-blue-300 opacity-20 transition-all"
x-bind:style="`width: ${file.progress}%`"
></div>
<div
class="relative z-20 flex items-center justify-between py-3 pl-3 pr-4 text-sm"
>
<div class="flex flex-1 items-center">
<x-heroicon-o-document
class="h-5 w-5 flex-shrink-0 text-gray-400"
/>
<span
class="ml-2 w-0 flex-1 truncate"
x-text="file.name"
></span>
<span x-show="file.error">Problem with file</span>
</div>
<div class="ml-4">
<button
x-on:click.prevent="removeFile(index)"
class="text-xs font-medium text-red-600"
>
Remove
</button>
</div>
</div>
</li>
</template>
</ul>
</div>
</div>
</div>
</div>
How it basically works.
Your alpine component has a uploadedFiles
array, which is bound to the Livewire component.
When you drop a file, or select a file, it will upload it to S3, and then add it to the uploadedFiles
array (which is entangled with the $uploadedFiles array on the Livewire component).
Once the file is uploaded, we call the startUpload
method on the Livewire component, which will loop through the uploadedFiles
array, and move each file from the temporary directory to the final directory
We then store the filepath and metadata in the database.
Note that we are not actually uploading the files to the Livewire component, we are just uploading them to S3 on the frontend (using the Signed Storage URLs provided by Vapor-core, using the Vapor frontend package), and then moving them to the final directory on the backend.