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 3, 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 3 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 3
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:
-
WithFileUploadstrait gives Livewire upload features -
$photosis an array of temporary uploaded files -
saveToLocal()stores images on thepublicdisk -
saveToS3()stores images on thes3disk (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.envwith real S3 credentials.
10. Conclusion
With Laravel 12 + Livewire 3, 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