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.