Skip to main content
Back to Blog

Laravel 12 Multiple Image Upload with Livewire 4 (Local & S3, 2025 Guide)

Laravel 12 Multiple Image Upload with Livewire 4 (Local & S3, 2025 Guide) – cover image

In almost every real-world Laravel project, you eventually bump into this requirement:

“We need to upload multiple images…
…show previews before saving…
…store them somewhere safe…
…and maybe push them to S3 later.”

If you try to do this manually with plain PHP and JavaScript, it quickly turns into a mess of $_FILES, JavaScript event listeners, and validation logic.

With Laravel 12 + Livewire 4, this becomes a lot simpler:

  • Live previews in the browser

  • Server-side validation

  • Easy storage in public/storage

  • Optional upload to Amazon S3 (or any S3-compatible provider like Wasabi / DigitalOcean Spaces)

In this tutorial, you’ll learn how to:

  • Build a Livewire 4 component for multiple image upload

  • Show image previews before saving

  • Save images locally (storage/app/public)

  • Optionally upload images to S3

  • Store image paths in the database

Let’s get started.


1. Prerequisites

You’ll need:

  • Laravel 12 project (PHP 8.2–8.4)

  • Composer

  • Node.js + npm (for asset building if needed)

  • A database configured

  • (Optional) AWS S3 or S3-compatible storage account

I’ll assume you already have Laravel 12 set up and running.

If not, you can create a new project:

laravel new livewire-multiple-upload cd livewire-multiple-upload

2. Install Livewire 4

Install Livewire:

composer require livewire/livewire

In your main layout (for example resources/views/layouts/app.blade.php), add:

<head> <!-- ... --> @livewireStyles </head> <body> <!-- ... --> @livewireScripts </body>

If you’re using a different layout (like your main-fe layout), just make sure both @livewireStyles and @livewireScripts are present.


3. Set Up Local Storage for Images

We’ll store images locally first.

Run:

php artisan storage:link

This creates a symlink:

  • From: public/storage

  • To: storage/app/public

Anything you store on the public disk will be accessible via /storage/....


4. Create a Migration & Model for Galleries

We’ll create a simple galleries table to store:

  • title (optional)

  • images (JSON array of file paths)

Run:

php artisan make:model Gallery -m

Edit the migration in database/migrations/...create_galleries_table.php:

public function up(): void { Schema::create('galleries', function (Blueprint $table) { $table->id(); $table->string('title')->nullable(); $table->json('images'); // array of image paths $table->timestamps(); }); }

Run migrations:

php artisan migrate

Now update app/Models/Gallery.php:

<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; class Gallery extends Model { protected $fillable = [ 'title', 'images', ]; protected $casts = [ 'images' => 'array', ]; }

5. Create a Livewire Component for Multiple Image Upload

Generate the component:

php artisan make:livewire GalleryUploader

This creates:

  • app/Livewire/GalleryUploader.php

  • resources/views/livewire/gallery-uploader.blade.php


5.1 Add Logic to GalleryUploader Component

Open app/Livewire/GalleryUploader.php:

<?php namespace App\Livewire; use App\Models\Gallery; use Illuminate\Support\Facades\Storage; use Livewire\Component; use Livewire\WithFileUploads; class GalleryUploader extends Component { use WithFileUploads; public string $title = ''; /** @var \Livewire\Features\SupportFileUploads\TemporaryUploadedFile[] */ public array $photos = []; public bool $uploading = false; protected array $rules = [ 'title' => ['nullable', 'string', 'max:255'], 'photos' => ['required', 'array', 'min:1'], 'photos.*' => ['image', 'max:2048'], // max 2MB per image ]; public function updatedPhotos(): void { // Validate as soon as files are selected $this->validateOnly('photos'); } public function saveToLocal(): void { $this->uploading = true; $validated = $this->validate(); $paths = []; foreach ($this->photos as $photo) { // Store on "public" disk inside "galleries" folder $paths[] = $photo->store('galleries', 'public'); } Gallery::create([ 'title' => $this->title ?: 'Untitled Gallery', 'images' => $paths, ]); // Reset component state $this->reset(['title', 'photos', 'uploading']); session()->flash('success', 'Gallery saved locally with ' . count($paths) . ' images.'); } public function saveToS3(): void { $this->uploading = true; $validated = $this->validate(); $paths = []; foreach ($this->photos as $photo) { // Store on "s3" disk inside "galleries" folder $paths[] = $photo->store('galleries', 's3'); } Gallery::create([ 'title' => $this->title ?: 'Untitled Gallery (S3)', 'images' => $paths, ]); $this->reset(['title', 'photos', 'uploading']); session()->flash('success', 'Gallery saved to S3 with ' . count($paths) . ' images.'); } public function render() { return view('livewire.gallery-uploader'); } }

Key points:

  • WithFileUploads trait gives Livewire upload features

  • $photos is an array of temporary uploaded files

  • saveToLocal() stores images on the public disk

  • saveToS3() stores images on the s3 disk (we’ll configure this soon)


5.2 Create the Blade View with Preview UI

Open resources/views/livewire/gallery-uploader.blade.php:

<section class="max-w-xl mx-auto space-y-6"> @if (session('success')) <div class="rounded-md bg-emerald-50 px-3 py-2 text-xs text-emerald-700 border border-emerald-200"> {{ session('success') }} </div> @endif <header class="space-y-1"> <h1 class="text-xl font-semibold text-gray-900"> Multiple Image Upload with Livewire 4 </h1> <p class="text-xs text-gray-500"> Select multiple images, preview them instantly, then upload locally or to S3. </p> </header> <form wire:submit.prevent="saveToLocal" class="space-y-4"> {{-- Title --}} <div class="space-y-1"> <label for="title" class="block text-xs font-medium text-gray-700"> Gallery title (optional) </label> <input id="title" type="text" wire:model.defer="title" class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500" placeholder="My trip photos, product gallery, etc." > @error('title') <p class="text-xs text-red-600">{{ $message }}</p> @enderror </div> {{-- File input --}} <div class="space-y-1"> <label class="block text-xs font-medium text-gray-700"> Images </label> <input type="file" wire:model="photos" multiple accept="image/*" class="block w-full text-xs text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-indigo-600 file:px-3 file:py-2 file:text-xs file:font-medium file:text-white hover:file:bg-indigo-700" > @error('photos') <p class="text-xs text-red-600">{{ $message }}</p> @enderror @error('photos.*') <p class="text-xs text-red-600">{{ $message }}</p> @enderror> <p class="text-[11px] text-gray-400"> You can select multiple images. Max 2 MB each. </p> </div> {{-- Preview --}} @if ($photos) <div class="space-y-2"> <p class="text-xs font-medium text-gray-700"> Preview ({{ count($photos) }} {{ Str::plural('image', count($photos)) }}) </p> <div class="grid grid-cols-3 gap-2"> @foreach ($photos as $photo) <div class="aspect-square overflow-hidden rounded-md border border-gray-200"> <img src="{{ $photo->temporaryUrl() }}" alt="Preview image" class="h-full w-full object-cover" > </div> @endforeach </div> </div> @endif {{-- Actions --}} <div class="flex flex-col sm:flex-row gap-2"> <button type="submit" wire:loading.attr="disabled" wire:target="saveToLocal,saveToS3,photos" class="inline-flex items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-xs font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70" > <span wire:loading.remove wire:target="saveToLocal"> Save to Local Storage </span> <span wire:loading wire:target="saveToLocal"> Uploading to local... </span> </button> <button type="button" wire:click="saveToS3" wire:loading.attr="disabled" wire:target="saveToLocal,saveToS3,photos" class="inline-flex items-center justify-center rounded-md bg-slate-800 px-3 py-2 text-xs font-medium text-white hover:bg-slate-900 disabled:cursor-not-allowed disabled:opacity-70" > <span wire:loading.remove wire:target="saveToS3"> Save to S3 </span> <span wire:loading wire:target="saveToS3"> Uploading to S3... </span> </button> </div> </form> </section>

This gives you:

  • Multiple file input

  • Live validation

  • Image previews using temporaryUrl()

  • Two buttons: save to local vs save to S3


6. Show the Component in a Page

Create a simple route in routes/web.php:

use App\Livewire\GalleryUploader; Route::get('/gallery/upload', GalleryUploader::class)->name('gallery.upload');

If you prefer a Blade wrapper:

Route::view('/gallery/upload', 'pages.gallery-upload');

Then in resources/views/pages/gallery-upload.blade.php:

@extends('layouts.app') @section('content') <livewire:gallery-uploader /> @endsection

Adjust layout names to match your project (e.g. layouts.main-fe on your site).


7. Configure S3 (or S3-Compatible) Storage

To support the saveToS3() method, set up your s3 disk.

In .env:

FILESYSTEM_DISK=public AWS_ACCESS_KEY_ID=your-key AWS_SECRET_ACCESS_KEY=your-secret AWS_DEFAULT_REGION=your-region AWS_BUCKET=your-bucket-name AWS_URL= AWS_USE_PATH_STYLE_ENDPOINT=false

In config/filesystems.php, make sure the s3 disk exists (it usually does by default):

'disks' => [ 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), 'url' => env('APP_URL').'/storage', 'visibility' => 'public', ], 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), ], ],

You can swap AWS with Wasabi, DigitalOcean Spaces, etc., by changing the endpoint + credentials. The Livewire code doesn’t change.


8. Displaying the Saved Gallery

Let’s quickly show how to display a saved gallery from local storage.

In a controller or Livewire component:

$gallery = Gallery::latest()->first();

In a Blade view:

@if ($gallery) <div class="max-w-xl mx-auto mt-8 space-y-3"> <h2 class="text-lg font-semibold text-gray-900"> {{ $gallery->title }} </h2> <div class="grid grid-cols-3 gap-2"> @foreach ($gallery->images as $imagePath) <div class="aspect-square overflow-hidden rounded-md border border-gray-200"> <img src="{{ Storage::disk('public')->url($imagePath) }}" alt="Gallery image" class="h-full w-full object-cover" > </div> @endforeach </div> </div> @endif

For S3 images, use:

src="{{ Storage::disk('s3')->url($imagePath) }}"

9. Best Practices & Tips

  • Validate every upload:
    Always validate file type & size via 'photos.*' => 'image|max:2048'.

  • Use queues for huge uploads:
    For very large batches, consider offloading heavy work to jobs.

  • Clean up unused files:
    When deleting galleries, also delete images from storage.

  • Use meaningful folder names:
    e.g. galleries/user-{$user->id} for multi-user systems.

  • Hide secrets:
    Never commit .env with real S3 credentials.


10. Conclusion

With Laravel 12 + Livewire 4, multiple file uploads become far less painful:

  • You keep your logic in PHP and Blade.

  • Livewire handles the temporary files and previews.

  • Laravel’s filesystem handles local and cloud storage cleanly.

You’ve built:

  • Multiple image upload UI

  • Live previews

  • Validation

  • Local storage

  • S3 storage

  • Database persistence

From here, you can extend this into:

  • User profile galleries

  • Product image management

  • Admin media libraries

  • Client project galleries