Laravel Magazine
Building a Drag-and-Drop File Upload Manager With Livewire 4

Building a Drag-and-Drop File Upload Manager With Livewire 4

Eric Van Johnson ·

Livewire 4's view-based components are the biggest change to the framework since it launched: logic, markup, styles, and scripts all live together in one file instead of being split across a class and a separate Blade template. It's a genuinely nice way to organize a component, and file uploads are a good showcase for it because there's meaningful logic, meaningful markup, and a bit of client-side polish, all in one cohesive unit.

This tutorial builds a drag-and-drop upload manager for a fictional PHP Architect internal tool: multiple file uploads, a progress bar per file, type and size validation, and a delete button, all in a single Livewire 4 component.

Step 1: Generate the component

php artisan make:livewire UploadManager

With Livewire 4, this generates a single file rather than a class-plus-view pair. Everything below lives in resources/views/livewire/upload-manager.blade.php.

Step 2: The logic

At the top of the file, inside a @php block, we define the component's state and methods, the same responsibilities a class-based component's PHP class would have handled.

@php

use Livewire\Attributes\Validate;
use Livewire\WithFileUploads;

new class extends \Livewire\Component
{
    use WithFileUploads;

    #[Validate([
        'files.*' => 'file|max:10240|mimes:pdf,png,jpg,jpeg,docx',
    ])]
    public array $files = [];

    public array $uploaded = [];

    public function updatedFiles(): void
    {
        $this->validate();

        foreach ($this->files as $file) {
            $path = $file->store('uploads', 'public');

            $this->uploaded[] = [
                'name' => $file->getClientOriginalName(),
                'size' => $file->getSize(),
                'path' => $path,
            ];
        }

        $this->files = [];
    }

    public function remove(string $path): void
    {
        \Illuminate\Support\Facades\Storage::disk('public')->delete($path);

        $this->uploaded = array_values(array_filter(
            $this->uploaded,
            fn ($item) => $item['path'] !== $path,
        ));
    }
}

@endphp

WithFileUploads is the same trait you'd have used in Livewire 3, that part didn't change. What's different is that this logic now sits directly above the markup that uses it instead of in a separate UploadManager.php class file you'd have to jump to.

Step 3: The markup

Right below the @endphp, in the same file:

<div
    x-data="{ isDragging: false }"
    x-on:dragover.prevent="isDragging = true"
    x-on:dragleave.prevent="isDragging = false"
    x-on:drop.prevent="isDragging = false"
>
    <label
        for="file-upload"
        class="block border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition"
        :class="isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'"
    >
        <span class="text-gray-600">Drag files here, or click to browse</span>
        <input
            id="file-upload"
            type="file"
            multiple
            wire:model="files"
            class="hidden"
        >
    </label>

    <div wire:loading wire:target="files" class="mt-3 text-sm text-gray-500">
        Uploading...
    </div>

    @error('files.*')
        <p class="mt-2 text-sm text-red-600">{{ $message }}</p>
    @enderror

    <ul class="mt-4 space-y-2">
        @foreach ($uploaded as $item)
            <li class="flex items-center justify-between border rounded p-3">
                <div>
                    <p class="font-medium">{{ $item['name'] }}</p>
                    <p class="text-xs text-gray-500">{{ number_format($item['size'] / 1024, 1) }} KB</p>
                </div>
                <button
                    wire:click="remove('{{ $item['path'] }}')"
                    wire:confirm="Delete this file?"
                    class="text-red-600 text-sm"
                >
                    Remove
                </button>
            </li>
        @endforeach
    </ul>
</div>

The drag-and-drop visual state, the border color change when you drag a file over the drop zone, is handled entirely by Alpine's x-data and x-on directives. The actual file input underneath is a plain HTML <input type="file"> with the native drag-and-drop behavior browsers already give you for free when a file element is styled to cover the drop area. Livewire doesn't need to know anything about the drag gesture itself, it only cares once a file lands in the input via wire:model.

Step 4: Real-time progress

wire:loading wire:target="files" covers the simple case of "show a spinner while uploading," but if you want a real percentage progress bar, Livewire exposes upload progress events you can hook into with a small bit of JavaScript in the same file's <script> block, another perk of view-based components: the script that manipulates this component's DOM lives right here instead of in a separate app-wide JS file.

<script>
document.addEventListener('livewire:init', () => {
    Livewire.on('upload:progress', ({ progress }) => {
        // update a progress bar element scoped to this component
    });
});
</script>

For most internal tools, the simpler wire:loading spinner is enough. Reach for manual progress events only if your users are regularly uploading large files where "it's working, just wait" isn't reassuring enough on its own.

Step 5: Server-side hardening

The validation rule already limits file size and mime type, but for anything user-facing, add a few more guardrails. Rate limit the upload action so someone can't hammer your storage disk:

public function updatedFiles(): void
{
    $key = 'uploads:' . auth()->id();

    if (RateLimiter::tooManyAttempts($key, 20)) {
        $this->addError('files', 'Too many uploads, try again in a minute.');
        return;
    }

    RateLimiter::hit($key, decaySeconds: 60);

    // ... existing validation and storage logic
}

And if these uploads are ever going to be publicly accessible, don't trust the client-reported mime type for anything security-sensitive, Laravel's mimes rule already checks the file's actual contents rather than its extension, which covers the common case, but scanning uploads for malware before they're served to other users is worth adding if this feature accepts files from people outside your own team.

Wrapping up

What used to be a UploadManager.php class, a separate upload-manager.blade.php view, and a mental map connecting the two is now one file you can read top to bottom: state, validation, storage logic, then the markup and any client-side script that goes with it. For a component like this one, where the PHP logic and the UI are tightly coupled anyway, that's a genuinely nicer way to work.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.