DEV Community

loading...

Reusable File Upload Component With Laravel Livewire and AlpineJS

Mithicher Baro
I'm a Web Developer
・8 min read

Laravel Livewire makes file uploading very easy, it's almost like magic to say. Here we will see how to create a modular upload component with the help of laravel blade component.

This post assumes you have a basic knowledge of Laravel Livewire and AlpineJS and have already installed a fresh copy of it along with Laravel.

Features it will include are:

  1. Upload Component - both single and multiple files
  2. Show progress when uploading
  3. Show preview of the uploaded file
  4. Validation
  5. Remove already uploaded file

Let us create a Livewire component SingleUpload

php artisan livewire:make SingleUpload
Enter fullscreen mode Exit fullscreen mode

After running this command two files will be generated in app/Http/Livewire and resources/views/livewire directory respectively.

  1. SingleUpload.php : The component class to handle our logic
  2. single-upload.blade.php : The view file for our component

SingleUpload.php

use Livewire\WithFileUploads;

class SingleUpload extends Component
{
    use WithFileUploads;

    public $photo;

    public function render()
    {
        return view('livewire.single-upload');
    }
}
Enter fullscreen mode Exit fullscreen mode

How to use in Laravel Blade Views

For single image upload the blade component will look like this:

<x-file-attachment wire:model="photo" :file="$photo" />
Enter fullscreen mode Exit fullscreen mode

For multiple image upload the blade component will look like this:

<x-file-attachment wire:model="photo" :file="$photo" multiple />
Enter fullscreen mode Exit fullscreen mode

For profile image upload the blade component will look like this:

<x-file-attachment 
    wire:model="photo" 
    :file="$photo" 
    mode="profile" 
    profile-class="w-24 h-24 rounded-lg" 
    accept="image/jpg,image/jpeg,image/png"
/>
Enter fullscreen mode Exit fullscreen mode

The full component is given below:

@props([
    'file' => null,
    'accept' => 'image/jpg,image/jpeg,image/png,application/pdf',
    'multiple' => false,
    'mode' => 'attachment',
    'profileClass' => 'w-20 h-20 rounded-full'
])

<div x-data="{ 
    isMultiple: Boolean('{{ $multiple }}') || false, 
    progress: 0,
    isFocused: false,
    handleFiles() { 
        if (this.isMultiple === true) {
            @this.uploadMultiple('{{ $attributes->wire('model')->value }}', this.$refs.input.files, () => {
            }, () => {
            }, (event) => {
                this.progress = event.detail.progress || 0
            })
        } else {
            @this.upload('{{ $attributes->wire('model')->value }}',  this.$refs.input.files[0], () => {
            }, () => {
            }, (event) => {
                this.progress = event.detail.progress || 0
            });
        }
    }
}"
x-cloak>
    @if(! $file || $mode === 'profile')
        @php $randomId = Str::random(6); @endphp
        <label for="file-{{ $randomId }}" class="relative block leading-tight bg-white hover:bg-gray-100 cursor-pointer inline-flex items-center transition duration-500 ease-in-out group overflow-hidden
            {{  $mode === 'profile' ? 'border group '. $profileClass : 'border-2 w-full pl-3 pr-4 py-2 rounded-lg border-dashed' }}
        "
        wire:loading.class="pointer-events-none"
        :class="{ 'border-indigo-300': isFocused === true }"
        >
            {{-- hack to get the progress of upload file --}}
            <input 
                type="hidden" 
                name="{{ $attributes->wire('model')->value }}" 
                {{ $attributes->wire('model') }}
            />

            <input 
                type="file" 
                id="file-{{ $randomId }}" 
                class="absolute inset-0 cursor-pointer opacity-0 text-transparent sr-only"
                accept="{{ $accept }}"
                :multiple="isMultiple"
                x-ref="input"
                x-on:change.prevent="handleFiles"
                x-on:focus="isFocused = true" 
                x-on:blur="isFocused = false" 
            />

            {{-- Upload Progress --}}
            <div wire:loading.flex wire:target="{{ $attributes->wire('model')->value }}" wire:loading.class="w-full">
                @if ($mode === 'profile' && $file)
                    <div class="select-none text-sm text-indigo-500 flex flex-1 items-center justify-center text-center p-4 flex-1">
                        <svg class="animate-spin h-6 w-6 text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
                            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                        </svg>
                    </div>
                @endif

                @if ($mode === 'attachment')
                    <div class="text-center flex-1 p-4">
                        <div class="mb-2">Uploading...</div>
                        <div>
                            <div class="h-3 relative max-w-lg mx-auto rounded-full overflow-hidden">
                                <div class="w-full h-full bg-gray-200 absolute"></div>
                                <div class="h-full bg-green-500 absolute" x-bind:style="'width:' + progress + '%'"></div>
                            </div>
                        </div>
                    </div>
                @endif
            </div>

            {{-- Preview for mode 'profile' --}}
            @if ($mode === 'profile' && $file)
                <div class="absolute inset-0">
                    <div class="relative w-full overflow-hidden shadow-inner" style="padding-bottom: 100%">
                        @if(collect(['jpg', 'png', 'jpeg', 'webp'])->contains($file->getClientOriginalExtension()))
                            <img src="{{ $file->temporaryUrl() }}" class="inset-0 w-full h-full absolute object-cover">

                            <div class="h-10 w-10 my-auto flex items-center justify-center inset-0 mx-auto rounded-full group-hover:bg-gray-400 group-hover:bg-opacity-25 text-white absolute z-10">
                                <svg class="h-6 w-6 text-gray-100" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
                                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
                                </svg>
                            </div>
                        @else
                            <span>Type invalid.</span>
                        @endif
                    </div>   
                </div>
            @endif

            {{-- Placeholder text for mode 'attachment' --}}
            <div class="flex items-center justify-center flex-1 px-4 py-2" wire:loading.class="hidden" wire:target="{{ $attributes->wire('model')->value }}">
                @if($slot->isEmpty())
                    @if($multiple)
                        <svg class="-mr-5 -mt-2 transform -rotate-6 h-8 w-8 text-gray-300 group-hover:text-indigo-300 transition duration-500 ease-in-out" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"></rect><g><circle cx="99.99951" cy="92" r="12"></circle><path d="M208.00049,31.99963h-160a16.01833,16.01833,0,0,0-16,16V175.97369l-.001.0307.001,31.99524a16.01833,16.01833,0,0,0,16,16h160a16.01833,16.01833,0,0,0,16-16v-160A16.01834,16.01834,0,0,0,208.00049,31.99963Zm-28.68653,80a16.019,16.019,0,0,0-22.62792,0l-44.68555,44.68653L91.314,135.99963a16.02161,16.02161,0,0,0-22.62792,0L48.00049,156.68457V47.99963h160l.00586,92.6922Z"></path></g></svg>
                        <svg class="transition duration-500 ease-in-out relative h-8 w-8 transform rotate-3 text-gray-400 group-hover:text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"></rect><path d="M168.001,100.00017v.00341a12.00175,12.00175,0,1,1,0-.00341ZM232,56V200a16.01835,16.01835,0,0,1-16,16H40a16.01835,16.01835,0,0,1-16-16V56A16.01835,16.01835,0,0,1,40,40H216A16.01835,16.01835,0,0,1,232,56Zm-15.9917,108.6936L216,56H40v92.68575L76.68652,112.0002a16.01892,16.01892,0,0,1,22.62793,0L144.001,156.68685l20.68554-20.68658a16.01891,16.01891,0,0,1,22.62793,0Z"></path></svg>
                    @else
                        @if ($mode === 'profile')
                            <svg class="h-8 w-8 text-gray-300 group-hover:text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
                              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
                            </svg>
                        @else
                            <svg class="h-8 w-8 text-gray-300 group-hover:text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"></rect><path d="M168.001,100.00017v.00341a12.00175,12.00175,0,1,1,0-.00341ZM232,56V200a16.01835,16.01835,0,0,1-16,16H40a16.01835,16.01835,0,0,1-16-16V56A16.01835,16.01835,0,0,1,40,40H216A16.01835,16.01835,0,0,1,232,56Zm-15.9917,108.6936L216,56H40v92.68575L76.68652,112.0002a16.01892,16.01892,0,0,1,22.62793,0L144.001,156.68685l20.68554-20.68658a16.01891,16.01891,0,0,1,22.62793,0Z"></path></svg>
                        @endif
                    @endif

                    @if ($mode === 'attachment')
                        <span class="ml-2 text-gray-600">{{ is_array($file) ? 'Browse files' : 'Browse file' }} | <span class="text-sm">PNG or JPEG</span></span>
                    @endif
                @else
                    {{ $slot }}
                @endif
            </div>
        </label>
    @endif

    @if ($mode === 'attachment')
        {{-- Loading indicator for file remove --}}
        <div wire:loading.delay wire:loading.flex wire:target="removeUpload" wire:loading.class="w-full">
            <div class="text-sm text-red-500 bg-red-100 flex-1 p-1 text-center rounded">
                Removing file...
            </div>
        </div>   

        {{-- Preview for mode 'attachment' --}} 
        <div>
            @if(is_array($file) && count($file) > 0)
                @foreach($file as $key => $f)
                    <div class="py-3 flex {{ !$loop->last ? 'border-b border-gray-200' : '' }}">
                        <div class="w-16 mr-4 flex-shrink-0 shadow-xs rounded-lg" >
                            @if(collect(['jpg', 'png', 'jpeg', 'webp'])->contains($f->getClientOriginalExtension()))
                                <div class="relative pb-16 overflow-hidden rounded-lg border border-gray-100">
                                    <img src="{{ $f->temporaryUrl() }}" class="w-full h-full absolute object-cover rounded-lg">
                                </div>
                            @else
                                <div class="w-16 h-16 bg-gray-100 text-blue-500 flex items-center justify-center rounded-lg border border-gray-100">
                                    <svg class="h-12 w-12" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
                                    </svg>
                                </div>
                            @endif
                        </div>
                        <div>
                            @if ($multiple)
                                {{-- prints attachment.* --}}
                                @error($attributes->wire('model')->value . '.'. $key)
                                    <p class="text-sm text-red-600" class="mb-2">{{ $message }}</p>
                                @enderror
                            @endif

                            <div class="text-sm font-medium truncate w-40 md:w-auto">{{ $f->getClientOriginalName() }}</div>
                            <div class="flex items-center space-x-1">
                                <div class="text-xs text-gray-500">{{ Str::bytesToHuman($f->getSize()) }}</div>
                                <div class="text-gray-400 text-xs">&bull;</div>
                                <div class="text-xs text-gray-500 uppercase">{{ $f->getClientOriginalExtension() }}</div>
                            </div>

                            <button 
                                wire:key="remove-attachment-{{ $f->getFilename() }}"
                                wire:loading.attr="disabled" 
                                type="button" 
                                x-on:click.prevent="$wire.removeUpload('{{ $attributes->wire('model')->value }}', '{{ $f->getFilename() }}')" class="text-xs text-red-500 appearance-none hover:underline">
                                Remove
                            </button>
                        </div>
                    </div>
                @endforeach
            @else
                @if($file)
                    <div class="mt-3 flex">
                        <div class="w-16 mr-4 flex-shrink-0 shadow-xs rounded-lg">
                            @if(collect(['jpg', 'png', 'jpeg', 'webp'])->contains($file->getClientOriginalExtension()))
                                <div class="relative pb-16 w-full overflow-hidden rounded-lg border border-gray-100">
                                    <img src="{{ $file->temporaryUrl() }}" class="w-full h-full absolute object-cover rounded-lg">
                                </div>
                            @else
                                <div class="w-16 h-16 bg-gray-100 text-blue-500 flex items-center justify-center rounded-lg border border-gray-100">
                                    <svg class="h-12 w-12" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
                                    </svg>
                                </div>
                            @endif
                        </div>
                        <div>

                            @error($attributes->wire('model')->value)
                                <p class="text-sm text-red-600" class="mb-2">{{ $message }}</p>
                            @enderror

                            <div class="text-sm font-medium truncate w-40 md:w-auto">{{ $file->getClientOriginalName() }}</div>
                            <div class="flex items-center space-x-1">
                                <div class="text-xs text-gray-500">{{ Str::bytesToHuman($file->getSize()) }}</div>
                                <div class="text-gray-400 text-xs">&bull;</div>
                                <div class="text-xs text-gray-500 uppercase">{{ $file->getClientOriginalExtension() }}</div>
                            </div>
                            <button
                                wire:loading.attr="disabled" 
                                type="button" 
                                x-on:click.prevent="$wire.removeUpload('{{ $attributes->wire('model')->value }}', '{{ $file->getFilename() }}')" class="text-xs text-red-500 appearance-none hover:underline">
                                Remove
                            </button>
                        </div>
                    </div>
                @endif
            @endif
        </div>
    @endif  
</div>
Enter fullscreen mode Exit fullscreen mode

Feel free to check out the source code on Github:
https://github.com/mithicher/file-attachment

Resources

For more information related to Livewire file uploads, visit:

Discussion (3)

Collapse
hakimd profile image
Hakim DAHOUNE

Hello @mithicher

Thanks for this great example.

I added the x-on:drop to the input to enable drag-drop, but it didn't work. Can you help please?

x-on:drop="handleFiles"

Collapse
mithicher profile image
Mithicher Baro Author

I will check on this later...meanwhile you can try this tailwindcomponents.com/component/d...

Collapse
hakimd profile image
Hakim DAHOUNE

never mind, I just noticed the "sr-only" which is making the input invisible :)
thanks though